"""Unit: the grounded-turn agent loop (phase 37, ``app.rag.agent``; the harness-aligned ``ls``/``read``/``grep`` surface, phase 70). A scripted fake LLM (canned stream sequences) + monkeypatched ``ls_top`` / ``ls_folder`` / ``list_source_names`` / ``find_document`` / ``all_documents`` — no database, no network. Covers the loop mechanics: the ls → read (combined ``source/path``) → answer happy path (event order, holder state, the tools staying offered on every request — phase 45 removed the per-tool budgets, the assistant/tool message history), the phase-94 drill-down ``ls`` (no-arg top level = the registered sources with counts + stored summaries in the pinned ``{N} sources:`` template, a source scope = its root folder — subfolders + capped file lines in the pinned folder template —, a ``source/folder`` scope = one level deeper, a registered source with 0 documents → the ``… — 0 documents, 0 folders:`` header counted, an unknown-source refusal that counts nothing, the NOT-A-FOLDER teaching with the parent's subfolders, the 50-file cap + grep-pointer note), ``read`` on the canonical combined form (split at the FIRST slash, full content, the bare-source-name refusal, the already-in-context dedupe, missing-args refusals), the phase-131 (task 02) range-read execution (the optional ``offset``/``limit``: :func:`agent._read_range_args` validation — whole ints ≥ 1, bools excluded — with the pinned :data:`agent.BAD_RANGE_ARGS` echo, the six-row COVERAGE dedupe matrix (phase 141, task 02 — the rework of the phase-131 pair-set matrix: a range read PROCEEDS unless the requested lines are FULLY covered by earlier reads — the owner's stuck-scenario fix, including the phase-131 truncated-then-range row; a plain re-read of a PARTIALLY-covered doc gets the pinned :data:`agent.REREAD_PARTIAL` teaching naming the covered span(s); re-reads of fully-covered lines stay ``ALREADY_IN_CONTEXT`` — the claim is then true; the raw-arg fast path is RETIRED — every dedupe row resolves the document first; the task-03 SCENARIO edge: two adjacent range reads covering half a 200-line doc — the merged span named in the plain re-read's teaching, the rest of the doc still readable), :data:`agent.OFFSET_PAST_END` (the true line count named), the slice math + the ``lines: a-b of N`` header shape (plain reads byte- identical to pre-phase-131), the cap on the slice with the slice- length tuple ``chars_total``), the phase-68 ``grep`` contract under its new name (the locked A5 pins: fixed substring, case-insensitive, 20-cap in catalog order, 200-char lines, locator-only — ``read_docs`` untouched, no-match lines counted), the round cap forcing a final no-tools answer, the phase-160 budget-telemetry trailer on every tool result (the ``[tool calls remaining: N]`` line decreasing — the last-call line, then the spent line; a rejection's refusal carries it too; the pure ``_budget_note`` boundary table), the kill switch (``agent_max_rounds=0`` single-call path, no ``role: "tool"`` message at all — no trailer), and the phase-67 per-round retries (a dead-then-recovered round restarts before its first piece with a ``RetryPiece``; a mid-stream drop stays terminal — locked A2; the forced final no-tools call retries too; ``llm_retries=0`` is one plain attempt; retries are invisible to the round cap; consumer abandon mid-retry-sleep leaks nothing), and the phase-192 (task 01) mid-turn ``VerbatimBlockPiece``: exactly ONE piece per SUCCESSFUL ``show_text`` — after the round's ``tool`` / ``tool_result`` pieces and before the next model round, the piece's ``block`` being the holder's own ``shown_texts`` entry — and ZERO pieces on every refusal path (missing ``path``, the ``answer_text=False`` posture, no document, an ``is_image`` row, the spent ``show_text_max_blocks`` budget, the already-shown dedupe) or on the ``BOR_ANSWER_TEXT=0`` turn where the tool is not even offered. """ from __future__ import annotations import asyncio import dataclasses import json import logging import uuid from collections.abc import AsyncGenerator, AsyncIterator, Callable, Sequence from copy import deepcopy from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, cast from unittest.mock import MagicMock import pytest from sqlalchemy.orm import Session from app.config import Settings 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, AgentPiece, MalformedReplyError, VerbatimBlockPiece, agent_tools, run_agent, ) 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 from app.rag.verbatim import VerbatimBlock if TYPE_CHECKING: from app.rag.scaffolding import ScaffoldingFilter def _settings(**kwargs: Any) -> Settings: kwargs.setdefault("_env_file", None) 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 #: DB column guarantees it for real rows. _FIXTURE_CREATED_AT = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) def _doc(source: str, path: str, title: str = "Title", content: str = "CONTENT") -> Document: return Document( id=uuid.uuid4(), source=source, path=path, full_path=f"/tmp/{path}", title=title, content=content, content_hash="0" * 64, created_at=_FIXTURE_CREATED_AT, ) class ScriptedLLM: """Canned stream sequences; records every ``chat_stream`` request so the tests can assert on the messages and the ``tools`` passthrough. Phase 71: when the caller passes a ``ScaffoldingFilter``, the canned content pieces are fed through it exactly like ``LLMClient.chat_stream`` (an empty clean result yields nothing; the held tail is flushed on normal completion) — so a scaffolding-only canned round streams no content pieces and leaves ``stripped_chars`` behind for the recovery policy to key on.""" def __init__(self, *streams: list[StreamPiece | ToolCallPiece]) -> None: self.streams: list[list[StreamPiece | ToolCallPiece]] = list(streams) self.requests: list[tuple[list[dict[str, Any]], list[dict[str, Any]] | None]] = [] async def chat_stream( self, messages: list[dict[str, str]], tools: list[dict[str, Any]] | None = None, scaffolding: ScaffoldingFilter | None = None, ) -> AsyncIterator[StreamPiece | ToolCallPiece]: self.requests.append((deepcopy(messages), tools)) if not self.streams: raise AssertionError("ScriptedLLM ran out of canned streams") pieces = self.streams.pop(0) if scaffolding is None: for piece in pieces: yield piece return for piece in pieces: if isinstance(piece, StreamPiece) and piece.kind == "content": cleaned = scaffolding.feed(piece.text) if cleaned: yield StreamPiece("content", cleaned) else: yield piece tail = scaffolding.flush() if tail: yield StreamPiece("content", tail) def _mock_session() -> Session: """A minimal mock Session for unit tests (DB accessors are monkeypatched). The mock works as a context manager: ``__enter__`` returns itself so ``with db_factory() as tool_db:`` binds *tool_db* to the same mock. """ mock = cast("Session", MagicMock()) mock.__enter__ = MagicMock(return_value=mock) mock.__exit__ = MagicMock(return_value=False) mock.scalar = MagicMock(return_value=None) mock.execute = MagicMock(return_value=MagicMock(scalars=MagicMock(return_value=[]))) return mock async def _run( llm: ScriptedLLM | FailingLLM, holder: AgentHolder, settings: Settings, seed_docs: list[Document] | None = None, history: Sequence[dict[str, Any]] = (), db_factory: Callable[[], Session] | None = None, ) -> list[AgentPiece]: """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). Phase 95: the loop may also yield a ``ToolResultPiece`` (a truncated ``read``), and phase 192 a ``VerbatimBlockPiece`` (a successful ``show_text``) — both members of the one :data:`app.rag.agent.AgentPiece` union.""" out: list[AgentPiece] = [] factory = db_factory or (lambda: _mock_session()) async for piece in run_agent( cast("LLMClient", llm), factory, system_prompt="SYSTEM_PROMPT", user_message="QUESTION", seed_docs=seed_docs or [], settings=settings, holder=holder, history=history, ): out.append(piece) return out # ---------- AGENT_TOOLS shape (phase 70: ls / read / grep) ---------- def test_agent_tools_names_and_parameters() -> None: by_name = {t["function"]["name"]: t for t in AGENT_TOOLS} assert len(AGENT_TOOLS) == 3 # ls / read / grep (phase 70) assert set(by_name) == {"ls", "read", "grep"} # The phase-37/68 names exist nowhere in the tool surface. assert not set(by_name) & {"list_documents", "read_document", "search_documents"} assert all(t["type"] == "function" for t in AGENT_TOOLS) ls = by_name["ls"]["function"] # Task 05 (live gate iteration 2): the one-call-at-a-time discipline # clause (the harness prior batches calls; the loop executes one # per round — the extras count as unexecuted in the gate). # Phase 94 (task 03): the description is the drill-down tree # contract (pinned copy — the tool-surface revision, owner # permission 2026-09-10, ``TODO.md`` L4): one level per call, # sources at the top, folders + files below, the file-line format # and the combined-identity handoff to read/grep intact. assert ls["description"] == ( "List the knowledge base as a tree, one level at a time. " "With no path: the synced sources — each with its document " "count and a summary of its contents. With a source name (no " "'/'): that source's top-level folders and files. With a " "`source/folder` path: that folder's subfolders and files. " "Folder lines carry a summary of what the folder contains. " "File lines are `source: X | path: Y | title: Z` — use the " "combined `source/path` with `read` and `grep`. Call one tool " "at a time — wait for this result before your next call." ) ls_params = ls["parameters"] assert ls_params["type"] == "object" assert ls_params["required"] == [] # path is optional assert set(ls_params["properties"]) == {"path"} assert ls_params["properties"]["path"]["type"] == "string" # Phase 94 (task 03): the 'path' argument teaches the drill-down # semantics — a source name lists that source's top level, a # `source/folder` path drills one level deeper, omitted lists # every source (pinned copy). assert ls_params["properties"]["path"]["description"] == ( "Optional — a source name (e.g. 'homelab') to list its top " "level, or a `source/folder` path to drill down (e.g. " "'homelab/active'). Omit it to list every source." ) read = by_name["read"]["function"] # Phase 118 (task 04, A6): the read description is rewritten for # the summary-seed mode — the section shows the # top-ranked documents' SUMMARIES (their full texts are NOT in # the prompt yet); read adds one of them (or any other document) # to the context; only an already-read document is refused. The # combined-identity handoff to the `ls` output / the # blocks stays; the one-call-at-a-time discipline clause (task # 05) survives byte-identical. Phase 131 (task 01): the old # truncation dead-end paragraph ("Follow it and use `grep` # (pattern) to locate the rest") is replaced by the working # workflow — grep reports line numbers, then read the part with # `offset` (1-based first line) + `limit` (number of lines). assert read["description"] == ( "The section shows the SUMMARIES of the " "top-ranked documents — their full texts are NOT in " "your prompt yet. Use this tool to add one of them (or " "any other document) to your context, by its combined " "`source/path` string, exactly as shown in the `ls` " "output or the blocks. Do not re-read a " "document you have already read — its full text is " "already in your prompt. Very large documents are " "truncated: you receive the first part plus a TRUNCATED " "notice naming how many more characters exist. To read " "the rest: use `grep` (pattern) to find where your text " "is — it reports line numbers — then call `read` again " "on the same document with `offset` (1-based first line) " "and `limit` (number of lines) to read exactly that " "part. Do not re-read a document you have already read " "in full — its full text is already in your prompt. " "For a document from a git source, the result also " "carries an `origin` line (and `branch` when pinned) — " "include that original source in your answer (see your " "tools instructions). Call one tool at a time — wait " "for this result before your next call." ) # The byte-preserved contracts inside the rewrite, pinned as # substrings: the combined source/path identity, the # phase-131 grep → read(offset, limit) workflow paragraph, the # one-call-at-a-time discipline sentence. assert ( "by its combined `source/path` string, exactly as shown in " "the `ls` output or the blocks" ) in read["description"] assert ( "Very large documents are truncated: you receive the first " "part plus a TRUNCATED notice naming how many more " "characters exist. To read the rest: use `grep` (pattern) " "to find where your text is — it reports line numbers — " "then call `read` again on the same document with " "`offset` (1-based first line) and `limit` (number of " "lines) to read exactly that part." ) in read["description"] assert ( "Call one tool at a time — wait for this result before " "your next call." ) in read["description"] # Phase 145 (task 02): the origin clause mirrors the TOOLS_SECTION # rule (the description points, the section teaches — the # phase-124 show_image "see the instructions" shape): a git- # sourced document's read result carries an `origin` line (and # `branch` when pinned); include that original source in the # answer. assert ( "For a document from a git source, the result also carries " "an `origin` line (and `branch` when pinned) — include that " "original source in your answer (see your tools instructions)." ) in read["description"] read_params = read["parameters"] assert read_params["type"] == "object" # Phase 131 (task 01, LOCKED A2): offset/limit are OPTIONAL — # ``required`` stays ``["path"]``. assert read_params["required"] == ["path"] assert set(read_params["properties"]) == {"path", "offset", "limit"} assert read_params["properties"]["path"]["type"] == "string" # The line-range parameters (phase 131, task 01, LOCKED A2): # BOTH integer, 1-based (``offset`` = first line, ``limit`` = # line count) — the pi-harness ``read`` shape the model was # trained on. The descriptions carry the grep → read(offset, # limit) teaching (pinned copy). assert read_params["properties"]["offset"]["type"] == "integer" assert read_params["properties"]["limit"]["type"] == "integer" assert read_params["properties"]["offset"]["description"] == ( "The first line to read, 1-based. Omit it to read from the " "beginning of the document. Use it to read a specific part " "of a large document — grep reports the line numbers of " "what you found." ) assert read_params["properties"]["limit"]["description"] == ( "The maximum number of lines to read, starting at offset. " "Omit it to read as many lines as fit under the character " "cap. Keep it small — you are reading a part, not the " "document." ) # The combined source/path string is the canonical document identity # (phase 70) — the description pins it with a worked example; the # bare-path contract is stated up front (phase 72, task 02). Phase # 118 (task 04, A6): the trailing clause is the do-not-RE-READ # teaching — the seeds are summary blocks, not full text, so a # first read of a suggested document succeeds and only an # already-read document is refused. assert read_params["properties"]["path"]["description"] == ( "The document to add to your context, as the combined " "`source/path` string exactly as shown in the `ls` output (e.g. " "'homelab/active/container_caddy/caddy.md'). A bare document " "path (without the source name) will not resolve. Do not " "re-read a document you have already read — its full " "text is already in your prompt." ) grep = by_name["grep"]["function"] # Task 05 (live gate iterations 2-6, refined in the 2026-09-03 # re-run): the pattern-only-is-the-knowledge-base-search clause # ("pass ONLY `pattern`") + the source-name-is-not-a-document # clause (the model kept scoping grep with an ls-style source name # — the 2026-09-03 incident loop shape, but on grep) plus the # one-call-at-a-time discipline clause. The 2026-09-05 incident # (the "Qwen 3.8" sample question — the harness prior is that grep # takes a REGEX; this grep is a fixed substring, owner-locked A5): # the plain-substring-never-a-regex clause states the contract up # front, so the regex-shaped first grep that does fire gets the # teaching no-match line instead of a trusted miss. assert grep["description"] == ( "Search the indexed documents for an exact string " "(case- and accent-insensitive — accents fold: `besta` " "matches `BESTÅ`) and return up to 20 matching lines " "as `source/path:line: text` — a locator, not a " "context-adder: read the winner with `read` — for a " "large document, use the reported line number as " "`offset`. The pattern is a plain substring, NEVER a " "regex — if a pattern with regex syntax (like '.*' or " "'\\.') comes back with no matches, retry with the plain " "text you expect to see. For a normal search pass ONLY " "`pattern` — it searches every document and that is how " "you search the knowledge base; never pass a source name " "as `path` (a source name is not a document). Call one " "tool at a time — wait for this result before your " "next call." ) grep_params = grep["parameters"] assert grep_params["type"] == "object" assert grep_params["required"] == ["pattern"] assert set(grep_params["properties"]) == {"pattern", "path"} assert all(p["type"] == "string" for p in grep_params["properties"].values()) assert grep_params["properties"]["pattern"]["description"] == ( "The exact text to search for (a plain " "substring — case- and accent-insensitive, " "accents fold: `besta` matches `BESTÅ` — " "not a regex — no '.*', no " "'\\.', no character classes)" ) # Phase 72 (task 02): the bare-path contract is stated up front; # task 05 (live gate iterations 1-8): the one-known-document clause # with a worked combined-identity example and the source-name ban # (the model kept scoping grep with an ls-style source name — the # incident loop shape, but on grep). # Iteration 8 drops the standalone 'homelab' from this negative # example — the gate's live telemetry showed the model emitting # exactly that value, and naming it beside the parameter risks # priming it (the negative-example effect). The 2026-09-03 re-run # makes the rarity explicit ("Rarely needed") and re-states the # pattern-only normal search. assert grep_params["properties"]["path"]["description"] == ( "Rarely needed — only for re-searching one " "document you already know: that document's " "combined `source/path` identity (e.g. " "'homelab/ansible/inventory.yaml'). Never a " "source name. A bare document path (without " "the source name) will not resolve. Omit it " "for a normal search (pass only `pattern`)." ) def test_agent_tools_order_is_ls_read_grep() -> None: """The listing → context → locator order the prompt teaches (the API layer and the mock key off the names).""" assert [t["function"]["name"] for t in AGENT_TOOLS] == ["ls", "read", "grep"] def test_refusal_constants_are_harness_aligned() -> None: """The updated module-level refusal lines (the names moved to the harness surface). The ALREADY_IN_CONTEXT line is a phase-72, task 05 gate-iteration teaching (live telemetry: the model repeated the terse phase-37 line) — same refusal behavior, the copy names the correct action.""" assert agent.ALREADY_IN_CONTEXT == ( "Already in your context — the full text is already in your " "prompt. Do not call read on it again; answer from that text." ) assert agent.UNKNOWN_TOOL == "Unknown tool." assert agent.MISSING_READ_ARGS == "read requires a string argument 'path'." assert agent.MISSING_SEARCH_ARGS == "grep requires a string argument 'pattern'." # Phase 94 (task 03): the phase-72 document-path teaching refusal is # DELETED (a ``/`` now names a folder — the drill-down contract); # the no-source refusal stays byte-identical (the task's "existing # refusal, teaching parenthetical intact" pin). assert not hasattr(agent, "LS_PATH_NOT_A_SOURCE") assert agent.NO_SOURCE_NOT_A_DIRECTORY.startswith( "No source named '{scope}' — check the ls output." ) assert agent.NO_SOURCE_NOT_A_DIRECTORY == ( "No source named '{scope}' — check the ls output. (The 'path' " "argument is a source name, not a directory — omit it to list " "every document.)" ) # Phase 94 (task 03): the NOT-A-FOLDER drill-down teaching template, # pinned byte-for-byte (argument echoed, parent's subfolders # listed), and the pinned file-line cap constant. assert agent.NOT_A_FOLDER == "'{arg}' is not a folder — {parent} has: {subfolders}" assert agent.LS_MAX_FILE_LINES == 50 # Phase 72 (task 02): the read/grep "did you mean …?" suggestion # templates, pinned byte-for-byte, and the suggestion cap. assert agent.NO_DOCUMENT_DID_YOU_MEAN == ( "No document at '{arg}' — did you mean '{source}/{path}'?" ) assert agent.NO_DOCUMENT_DID_YOU_MEAN_MANY == ( "No document at '{arg}' — did you mean one of: {candidates}?" ) assert agent.SUGGESTION_LIMIT == 3 # ---------- list_source_names (the scoped ls registry join) ---------- def test_list_source_names_resolves_registry_rows( monkeypatch: pytest.MonkeyPatch, ) -> None: """Names resolve exactly as the import pipeline indexes them (the phase-69 ``resolve_source_name`` expressions — reuse, not re-derivation), deduped (two rows resolving to the same name share documents), in registry order.""" rows = [ GitSource(url="https://github.com/reese/homelab.git", kind="git"), GitSource( url="/srv/reese/deployments", kind="local", path="/srv/reese/deployments" ), # The phase-69 sibling case: a second row, same resolved name. GitSource(url="https://github.com/reese/homelab", kind="git"), ] monkeypatch.setattr(agent, "effective_sources", lambda db: (rows, "db")) assert agent.list_source_names(cast("Session", object())) == [ "homelab", "deployments", ] def test_list_source_names_empty_registry(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(agent, "effective_sources", lambda db: ([], "env")) assert agent.list_source_names(cast("Session", object())) == [] # ---------- happy path: ls → read (combined path) → answer ---------- def test_ls_then_read_then_answer(monkeypatch: pytest.MonkeyPatch) -> None: # Phase 94 (task 03): the no-arg ``ls`` is the drill-down TOP level # (the registered sources, registry order) — monkeypatched the way # the phase-70 full-catalog listing used to be. monkeypatch.setattr( agent, "ls_top", lambda db: [("Deployments", 1, None), ("Homelab", 1, None)] ) target = _doc("Homelab", "aws-route53.md", "AWS Route53 Records", "R53-CONTENT") calls: list[tuple[str, str]] = [] def _find(db: Any, source: str, path: str) -> Document | None: calls.append((source, path)) return target if (source, path) == ("Homelab", "aws-route53.md") else None monkeypatch.setattr(agent, "find_document", _find) seed = [_doc("Homelab", "kubernetes.md", "Kubernetes", "K8S-CONTENT")] holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [ ToolCallPiece( id="call_2", name="read", arguments={"path": "Homelab/aws-route53.md"}, ) ], [StreamPiece("thinking", "hmm "), StreamPiece("content", "Done! ")], ) pieces = asyncio.run(_run(llm, holder, _settings(), seed_docs=seed)) # Event order: tool pieces before the answer content/thinking. assert [type(p) for p in pieces] == [ ToolCallPiece, ToolCallPiece, StreamPiece, StreamPiece, ] assert pieces[0] == ToolCallPiece(id="call_1", name="ls", arguments={}) assert pieces[1] == ToolCallPiece( id="call_2", name="read", arguments={"path": "Homelab/aws-route53.md"} ) assert pieces[3] == StreamPiece("content", "Done! ") # The read document is recorded for done.sources / query_log (task 04). assert holder.read_docs == [target] assert holder.tool_calls == 2 # 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] == _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 # lookup, no self-correction candidates (phase 70). assert calls == [("Homelab", "aws-route53.md")] # The follow-up request carries the assistant tool-call + tool # result (phase 106, D5: the ``date:`` second line rides every # read — the first line is byte-identical). msgs = llm.requests[1][0] assert msgs[0] == {"role": "system", "content": "SYSTEM_PROMPT"} assert msgs[1] == {"role": "user", "content": "QUESTION"} assert msgs[2] == { "role": "assistant", "content": None, "tool_calls": [ { "id": "call_1", "type": "function", "function": {"name": "ls", "arguments": "{}"}, } ], } assert msgs[3]["role"] == "tool" assert msgs[3]["tool_call_id"] == "call_1" # Phase 160 (task 01): the budget-telemetry trailer rides the # result — round 1 of a 10-round budget reads 9 left. assert msgs[3]["content"] == ( "2 sources:\n\nDeployments — 1 documents\nHomelab — 1 documents" + agent.TOOL_BUDGET_REMAINING.format(n=9) ) # The second follow-up request carries the read call + the FULL text. msgs = llm.requests[2][0] assert msgs[4]["role"] == "assistant" assert msgs[4]["tool_calls"][0]["id"] == "call_2" assert json.loads(msgs[4]["tool_calls"][0]["function"]["arguments"]) == { "path": "Homelab/aws-route53.md" } assert msgs[5]["role"] == "tool" assert msgs[5]["tool_call_id"] == "call_2" # Phase 160 (task 01): round 2 of a 10-round budget reads 8 left. assert msgs[5]["content"] == ( "Document Homelab/aws-route53.md:\n" "date: 2024-06-15\n" "R53-CONTENT" + agent.TOOL_BUDGET_REMAINING.format(n=8) ) # full text, no cap (date: the D5 second line) + the phase-160 trailer def test_content_and_tool_call_in_one_stream_keeps_both( monkeypatch: pytest.MonkeyPatch, ) -> None: """Rare stream with content AND a tool call: the content stays (it was already emitted) and the tool still runs.""" monkeypatch.setattr(agent, "ls_top", lambda db: []) holder = AgentHolder() llm = ScriptedLLM( [ StreamPiece("content", "Let me check "), ToolCallPiece(id="call_1", name="ls", arguments={}), ], [StreamPiece("content", "the answer")], ) pieces = asyncio.run(_run(llm, holder, _settings())) assert [type(p) for p in pieces] == [StreamPiece, ToolCallPiece, StreamPiece] assert holder.tool_calls == 1 # the tool ran despite the content assert llm.requests[1][0][3]["content"].startswith("0 sources:") # ---------- phase 74: client history between system and user ---------- def test_run_agent_default_history_keeps_two_message_request() -> None: """No *history* (the default ``()``) → the model sees exactly the pre-phase-74 two-message request ``[system, user]`` — byte-identical behavior (owner-locked A2).""" llm = ScriptedLLM([StreamPiece("content", "the answer")]) asyncio.run(_run(llm, AgentHolder(), _settings())) (messages, _tools) = llm.requests[0] assert messages == [ {"role": "system", "content": "SYSTEM_PROMPT"}, {"role": "user", "content": "QUESTION"}, ] def test_run_agent_places_history_between_system_and_user() -> None: """A non-empty *history* (the client's prior turns, already mapped by ``history_to_messages``) is spliced between the system prompt and the CURRENT user message — oldest-first, with the assistant turn's prior thinking riding on ``reasoning_content`` (A4). The current question stays LAST.""" history = [ {"role": "user", "content": "old question"}, { "role": "assistant", "content": "old answer", "reasoning_content": "old thinking", }, ] llm = ScriptedLLM([StreamPiece("content", "the answer")]) pieces = asyncio.run( _run(llm, AgentHolder(), _settings(), history=history) ) assert [p for p in pieces if isinstance(p, StreamPiece)] == [ StreamPiece("content", "the answer") ] (messages, _tools) = llm.requests[0] assert messages == [ {"role": "system", "content": "SYSTEM_PROMPT"}, {"role": "user", "content": "old question"}, { "role": "assistant", "content": "old answer", "reasoning_content": "old thinking", }, {"role": "user", "content": "QUESTION"}, ] def test_run_agent_history_survives_a_tool_round( monkeypatch: pytest.MonkeyPatch, ) -> None: """The tool rounds append assistant/tool messages to the SAME ``messages`` list — the prior history stays in place between the system prompt and the current question on the SECOND request too.""" monkeypatch.setattr(agent, "ls_top", lambda db: []) llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [StreamPiece("content", "the answer")], ) history = [{"role": "assistant", "content": "old answer"}] asyncio.run(_run(llm, AgentHolder(), _settings(), history=history)) _first, second = llm.requests assert second[0][:3] == [ {"role": "system", "content": "SYSTEM_PROMPT"}, {"role": "assistant", "content": "old answer"}, {"role": "user", "content": "QUESTION"}, ] # ---------- ls: the drill-down tree (phase 94, task 03) ---------- def test_ls_top_level_lists_sources_with_summaries( monkeypatch: pytest.MonkeyPatch, ) -> None: """No argument: the TOP level — the registered sources, registry order, each ``{source} — {n} documents`` + the indented summary line only when stored — the pinned template, counted; the registry IS consulted (unlike the phase-70 full catalog, the top level is the registry itself).""" monkeypatch.setattr( agent, "ls_top", lambda db: [ ("Deployments", 0, None), ("Homelab", 1, "The homelab notes."), ], ) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "2 sources:\n" "\n" "Deployments — 0 documents\n" "Homelab — 1 documents\n" " The homelab notes." ) assert holder.tool_calls == 1 def test_ls_empty_registry_says_zero_sources(monkeypatch: pytest.MonkeyPatch) -> None: """No registered sources: the top level is the header line alone — ``0 sources:`` (the old ``0 documents:`` behavior preserved in spirit), still a counted result.""" monkeypatch.setattr(agent, "ls_top", lambda db: []) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith("0 sources:") assert holder.tool_calls == 1 @pytest.mark.parametrize( ("arguments", "label"), [ ({"path": " "}, "blank path"), ({"path": 7}, "non-string path"), ], ) def test_ls_blank_path_lists_top_level( monkeypatch: pytest.MonkeyPatch, arguments: dict[str, Any], label: str ) -> None: """A blank (or non-string) ``path`` is treated as omitted — the top level (the sources), counted (no refusal for an empty scope).""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments=arguments)], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith("1 sources:\n\nS — 1 documents") assert holder.tool_calls == 1 def test_ls_source_scope_lists_root_folder(monkeypatch: pytest.MonkeyPatch) -> None: """A registered source name (no ``/``): the source's ROOT folder — subfolders (2-space-indented, path order, ``: {summary}`` only when stored) + the root's own file lines in EXACTLY the ``source: X | path: Y | title: Z | date: YYYY-MM-DD`` format (the phase-106 D5 appended date field) — the pinned template, counted.""" monkeypatch.setattr( agent, "ls_folder", lambda db, source, folder: ( [("backups", 2, "Backup notes."), ("networking", 1, None)], [("Homelab", "readme.md", "Readme", "2024-06-15")], 1, ), ) monkeypatch.setattr(agent, "list_source_names", lambda db: ["Deployments", "Homelab"]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={"path": "Homelab"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "Homelab — 1 documents, 2 folders:\n" "\n" " backups/ — 2 documents: Backup notes.\n" " networking/ — 1 documents\n" "\n" "source: Homelab | path: readme.md | title: Readme | date: 2024-06-15" ) assert holder.tool_calls == 1 def test_ls_nested_folder_scope_lists_one_level_deeper( monkeypatch: pytest.MonkeyPatch, ) -> None: """A ``source/folder`` path: that folder's subfolders + own file lines, identity = ``source/folder`` (the same template as the root), counted; the fetchers are the source-scoped ones.""" def _rows(db: Any, source: str) -> list[tuple[str, str, str]]: assert source == "Homelab" # db is a mock session (SEC-14-04) return [ ("networking/lan.md", "LAN", "2024-06-15"), ("networking/vpn.md", "VPN", "2024-06-15"), ] monkeypatch.setattr(agent, "_source_document_rows", _rows) monkeypatch.setattr( agent, "_source_folder_summaries", lambda db, source: {"networking": "Network notes."} ) monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab"]) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="ls", arguments={"path": "Homelab/networking"} ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "Homelab/networking — 2 documents, 0 folders:\n" "\n" "source: Homelab | path: networking/lan.md | title: LAN | date: 2024-06-15\n" "source: Homelab | path: networking/vpn.md | title: VPN | date: 2024-06-15" ) assert holder.tool_calls == 1 def test_ls_scoped_known_source_with_zero_docs_counts( monkeypatch: pytest.MonkeyPatch, ) -> None: """A registered source with no indexed documents is KNOWN (the registry is the source of truth, not the catalog): it lists its header line alone (``… — 0 documents, 0 folders:`` — the old ``0 documents:`` behavior preserved in spirit) — a valid, counted result, not a refusal.""" monkeypatch.setattr( agent, "ls_folder", lambda db, source, folder: ([], [], 0) ) monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab", "Other"]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={"path": "Homelab"})], [StreamPiece("content", "ans")], ) 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] == _OFFER def test_ls_scoped_unknown_source_refused(monkeypatch: pytest.MonkeyPatch) -> None: """A ``path`` without ``/`` matching no source name is a refusal — the extended line with the teaching parenthetical (phase 72), not counted, the round cap bounds its repetition.""" monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={"path": "Ghost"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.tool_calls == 0 # a refusal counts in nothing assert llm.requests[1][0][3]["content"].startswith( agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="Ghost") ) assert llm.requests[1][1] == _OFFER # rejected → tools stay offered def test_ls_path_like_scope_unknown_source_gets_no_source_refusal( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 94: a ``/`` now names a folder, so the phase-72 document-path teaching is DELETED — a ``source/…`` argument whose FIRST segment names no registered source gets the no-source refusal (the segment echoed), counted in nothing, tools stay offered.""" monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"]) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="ls", arguments={"path": "app/rag/importer.py"}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.tool_calls == 0 # a refusal counts in nothing assert llm.requests[1][0][3]["content"].startswith( agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="app") ) assert llm.requests[1][1] == _OFFER # rejected → tools stay offered def test_ls_dot_scope_gets_not_a_directory_teaching_refusal( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 72: ``ls(path='.')`` (the incident's second round — no ``/``, no matching source) gets the extended no-source refusal with the teaching parenthetical, ``'.'`` echoed — not counted, tools stay offered (unchanged by phase 94).""" monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={"path": "."})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.tool_calls == 0 # a refusal counts in nothing assert llm.requests[1][0][3]["content"].startswith( agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope=".") ) assert llm.requests[1][1] == _OFFER # rejected → tools stay offered def test_ls_unknown_top_level_folder_gets_not_a_folder_teaching( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 94: a ``source/…`` argument whose TOP-LEVEL folder segment matches no indexed prefix gets the NOT-A-FOLDER teaching — the argument echoed, the source named, its direct subfolders listed so the model self-corrects in the next round; not counted, tools stay offered.""" monkeypatch.setattr( agent, "_source_document_rows", lambda db, source: [ ("backups/cron.md", "Cron", "2024-06-15"), ("containers/caddy.md", "Caddy", "2024-06-15"), ("networking/lan.md", "LAN", "2024-06-15"), ], ) monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {}) monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab"]) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="ls", arguments={"path": "Homelab/netwoking"} ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.tool_calls == 0 # a refusal counts in nothing assert llm.requests[1][0][3]["content"].startswith( "'Homelab/netwoking' is not a folder — Homelab has: " "backups/ containers/ networking/" ) assert llm.requests[1][1] == _OFFER # rejected → tools stay offered def test_ls_unknown_nested_folder_gets_not_a_folder_with_nested_parent( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 94: a nested miss names the DEEPEST existing ancestor — ``source/folder`` — and lists ITS direct subfolders (bounded: the parent's own listing, no new flood path).""" monkeypatch.setattr( agent, "_source_document_rows", lambda db, source: [ ("networking/lan/a.md", "A", "2024-06-15"), ("networking/vpn/b.md", "B", "2024-06-15"), ("readme.md", "Readme", "2024-06-15"), ], ) monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {}) monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab"]) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="ls", arguments={"path": "Homelab/networking/lan/x"} ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.tool_calls == 0 assert llm.requests[1][0][3]["content"].startswith( "'Homelab/networking/lan/x' is not a folder — " "Homelab/networking/lan has: none" ) def test_ls_file_path_scope_gets_not_a_folder(monkeypatch: pytest.MonkeyPatch) -> None: """Phase 94: a document's OWN path is never a folder (nothing starts with ``path + '/'``) — ``ls`` of a file path refuses with the NOT-A-FOLDER teaching (the parent's subfolders listed).""" monkeypatch.setattr( agent, "_source_document_rows", lambda db, source: [ ("notes.md", "Notes", "2024-06-15"), ("a/b.md", "B", "2024-06-15"), ], ) monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {}) monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"]) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece(id="call_1", name="ls", arguments={"path": "S/notes.md"}) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.tool_calls == 0 assert llm.requests[1][0][3]["content"].startswith( "'S/notes.md' is not a folder — S has: a/" ) # ---------- ls_top / ls_folder: the drill-down accessors (pure + composed) ---------- def test_ls_top_registry_order_zero_docs_and_summaries( monkeypatch: pytest.MonkeyPatch, ) -> None: """``ls_top``: registry order (not catalog order), a 0-document source still lists, the summary is the stored ``(source, "")`` row or ``None`` when absent; an empty registry → ``[]``.""" monkeypatch.setattr(agent, "list_source_names", lambda db: ["Zeta", "Alpha"]) monkeypatch.setattr( agent, "_source_document_counts", lambda db: [("Zeta", 3), ("Beta", 1)] ) monkeypatch.setattr( agent, "_source_root_summaries", lambda db: [("Zeta", "Zeta stuff.")] ) assert agent.ls_top(cast("Session", object())) == [ ("Zeta", 3, "Zeta stuff."), ("Alpha", 0, None), # 0 docs (no count row) + no stored summary ] def test_ls_top_empty_registry_is_empty( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(agent, "list_source_names", lambda db: []) def _boom(*_a: Any, **_k: Any) -> None: raise AssertionError("no fetches for an empty registry") monkeypatch.setattr(agent, "_source_document_counts", _boom) monkeypatch.setattr(agent, "_source_root_summaries", _boom) assert agent.ls_top(cast("Session", object())) == [] def test_ls_folder_composes_the_fetchers(monkeypatch: pytest.MonkeyPatch) -> None: """``ls_folder`` = the source's document rows + stored summaries through the pure :func:`group_folder_listing` (the fetchers are the monkeypatch surface).""" seen: list[tuple[str, str, str]] = [] def _rows(db: Any, source: str) -> list[tuple[str, str, str]]: seen.append(("rows", source, "")) return [("a/b.md", "B", "2024-06-15"), ("a.md", "A", "2024-06-15")] def _summaries(db: Any, source: str) -> dict[str, str]: seen.append(("summaries", source, "")) return {"a": "A stuff."} monkeypatch.setattr(agent, "_source_document_rows", _rows) monkeypatch.setattr(agent, "_source_folder_summaries", _summaries) assert agent.ls_folder(cast("Session", object()), "S", "") == ( [("a", 1, "A stuff.")], [("S", "a.md", "A", "2024-06-15")], 1, ) assert seen == [("rows", "S", ""), ("summaries", "S", "")] def test_group_folder_listing_subfolder_recursion_and_counts() -> None: """The recursive count per subfolder — every path equal to the folder or starting with ``folder + '/'`` (a doc under ``a/b/`` counts for BOTH ``a`` and ``a/b``), path order, the stored summary attached or ``None``.""" rows = [ ("a/b/c.md", "C", "2024-06-15"), ("a/b/d.md", "D", "2024-06-15"), ("a/e.md", "E", "2024-06-15"), ("f.md", "F", "2024-06-15"), ] sub, files, total = agent.group_folder_listing( "S", "", rows, {"a": "A subtree.", "a/b": "B subtree."} ) # ROOT level: the direct subfolders of "" are the TOP-LEVEL folders # only (a/b is nested under a, not direct) — a's count is its whole # recursive subtree (a/e.md + a/b/c.md + a/b/d.md), the stored # summary attached. assert sub == [("a", 3, "A subtree.")] assert files == [("S", "f.md", "F", "2024-06-15")] assert total == 1 # One level down: a/b is a's direct subfolder with its own count. sub2, _files2, _total2 = agent.group_folder_listing("S", "a", rows, {"a/b": "B subtree."}) assert sub2 == [("a/b", 2, "B subtree.")] def test_group_folder_listing_nested_level_counts_and_membership() -> None: """One level down: ``a``'s direct subfolder is ``a/b`` (count 2), its own direct file is ``a/e.md`` (``a/b/c.md`` is NOT a direct file of ``a``) — membership is the folder_of rule, order is path order.""" rows = [ ("a/b/c.md", "C", "2024-06-15"), ("a/b/d.md", "D", "2024-06-15"), ("a/e.md", "E", "2024-06-15"), ] sub, files, total = agent.group_folder_listing("S", "a", rows, {}) assert sub == [("a/b", 2, None)] assert files == [("S", "a/e.md", "E", "2024-06-15")] assert total == 1 def test_group_folder_listing_file_path_is_not_a_folder() -> None: """A document whose path is a prefix of NO other path is a file, never a folder: ``ls`` of it must not list a subfolder (and the ``path == folder`` count arm only fires for TRUE folders — a doc sharing a real folder's name counts for that folder, the existence rule intact).""" rows = [ ("a.md", "A", "2024-06-15"), # a file at the root, and a folder name? NO — ("b/x.md", "X", "2024-06-15"), # nothing starts with "a.md/" ] sub, files, total = agent.group_folder_listing("S", "", rows, {}) assert sub == [("b", 1, None)] # "a.md" is NOT a subfolder assert files == [("S", "a.md", "A", "2024-06-15")] # b/x.md is NOT a direct root file assert total == 1 # The path == folder arm: a doc named "a" under a real folder "a/". rows2 = [("a", "FileA", "2024-06-15"), ("a/c.md", "C", "2024-06-15")] sub2, files2, total2 = agent.group_folder_listing("S", "", rows2, {}) assert sub2 == [("a", 2, None)] # the file "a" counts for folder "a" assert files2 == [("S", "a", "FileA", "2024-06-15")] # …and is a direct ROOT file assert total2 == 1 def test_group_folder_listing_caps_files_at_fifty_keeps_the_total() -> None: """The cap: 51 direct files → 50 file lines + the PRE-cap total (51) for the renderer's note; 50 files → 50 lines, no note material. A 500-file folder costs 50 lines, never 500.""" rows51 = [(f"big/f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(51)] sub, files, total = agent.group_folder_listing("S", "big", rows51, {}) assert sub == [] assert total == 51 assert len(files) == 50 assert files[0] == ("S", "big/f000.md", "T0", "2024-06-15") assert files[-1] == ("S", "big/f049.md", "T49", "2024-06-15") rows50 = [(f"big/f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(50)] _sub, files50, total50 = agent.group_folder_listing("S", "big", rows50, {}) assert total50 == 50 and len(files50) == 50 # ---------- phase 119 (D3): the suggested-folder context lines ---------- def _patch_catalog( monkeypatch: pytest.MonkeyPatch, catalog: dict[str, list[tuple[str, str, str]]] ) -> None: """Monkeypatch the ``ls`` fetchers (house style) with one source catalog of ``(path, title, date)`` rows per source name.""" monkeypatch.setattr( agent, "_source_document_rows", lambda db, source: catalog.get(source, []) ) monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {}) def test_suggested_folder_lines_source_root_doc_lists_the_top_level( monkeypatch: pytest.MonkeyPatch, ) -> None: """A suggested doc at the source root (``""`` prefix) renders the ``/: …`` line with the source's top level — subfolders with recursive counts, the suggested doc itself excluded.""" _patch_catalog( monkeypatch, { "Homelab": [ ("a/b/one.md", "One", "2024-06-15"), ("a/b/two.md", "Two", "2024-06-15"), ("a/c.md", "C", "2024-06-15"), ("z.md", "Z", "2024-06-15"), ] }, ) assert agent.suggested_folder_lines( cast("Session", object()), [_doc("Homelab", "z.md")] ) == ["Homelab/: a/ (3 docs)"] def test_suggested_folder_lines_nested_doc_lists_the_parent_folder( monkeypatch: pytest.MonkeyPatch, ) -> None: """A nested suggested doc renders the PARENT folder's line — the direct children only: subfolder names (source-relative, recursive counts) + the files by relative filename, the suggested doc itself out of the entries.""" _patch_catalog( monkeypatch, { "Homelab": [ ("a/b/one.md", "One", "2024-06-15"), ("a/b/two.md", "Two", "2024-06-15"), ("a/c/d.md", "D", "2024-06-15"), ("a/e.md", "E", "2024-06-15"), ] }, ) # The parent of a/b/one.md is a/b: one.md excluded, two.md stays. assert agent.suggested_folder_lines( cast("Session", object()), [_doc("Homelab", "a/b/one.md")] ) == ["Homelab/a/b/: two.md"] # The parent of a/e.md is a: the subfolders (recursive counts — a/b # carries two, a/c one) first, then the files; e.md excluded, so no # file entries remain. assert agent.suggested_folder_lines( cast("Session", object()), [_doc("Homelab", "a/e.md")] ) == ["Homelab/a/: a/b/ (2 docs), a/c/ (1 doc)"] def test_suggested_folder_lines_excludes_only_the_owning_doc( monkeypatch: pytest.MonkeyPatch, ) -> None: """The doc that OWNS the line (the first suggested doc in that folder) is the only one excluded — a folder whose sole entry was the suggested doc renders its header alone (the empty-level precedent).""" _patch_catalog( monkeypatch, { "Homelab": [("a/only.md", "Only", "2024-06-15")] }, ) assert agent.suggested_folder_lines( cast("Session", object()), [_doc("Homelab", "a/only.md")] ) == ["Homelab/a/:"] def test_suggested_folder_lines_dedupes_by_source_and_prefix( monkeypatch: pytest.MonkeyPatch, ) -> None: """Dedupe: two suggested docs in the same folder ⇒ ONE line (the first doc's position, the first doc excluded, the second listed); the key is ``(source, prefix)`` — the same prefix under a DIFFERENT source is a distinct line; suggested-doc order is the line order.""" _patch_catalog( monkeypatch, { "Homelab": [ ("a/b/one.md", "One", "2024-06-15"), ("a/b/two.md", "Two", "2024-06-15"), ("a/c.md", "C", "2024-06-15"), ("a/d/e.md", "E", "2024-06-15"), ("z.md", "Z", "2024-06-15"), ], "Other": [("a/b/x.md", "X", "2024-06-15")], }, ) docs = [ _doc("Homelab", "z.md"), _doc("Homelab", "a/b/one.md"), _doc("Homelab", "a/b/two.md"), # deduped (a/b already seen) _doc("Homelab", "a/c.md"), _doc("Homelab", "a/d/e.md"), _doc("Other", "a/b/x.md"), # same prefix, different source ] assert agent.suggested_folder_lines(cast("Session", object()), docs) == [ "Homelab/: a/ (4 docs)", "Homelab/a/b/: two.md", "Homelab/a/: a/b/ (2 docs), a/d/ (1 doc)", "Homelab/a/d/:", "Other/a/b/:", ] def test_suggested_folder_lines_max_lines_caps_the_list( monkeypatch: pytest.MonkeyPatch, ) -> None: """At most *max_lines* lines — the walk stops, suggested-doc order kept (the first distinct folders win).""" _patch_catalog( monkeypatch, { "Homelab": [ ("f1/x.md", "1", "2024-06-15"), ("f2/x.md", "2", "2024-06-15"), ("f3/x.md", "3", "2024-06-15"), ] }, ) docs = [_doc("Homelab", p) for p in ("f1/x.md", "f2/x.md", "f3/x.md")] lines = agent.suggested_folder_lines(cast("Session", object()), docs, max_lines=2) assert lines == ["Homelab/f1/:", "Homelab/f2/:"] assert agent.suggested_folder_lines( cast("Session", object()), docs, max_lines=1 ) == ["Homelab/f1/:"] def test_suggested_folder_lines_max_entries_cap_and_remainder( monkeypatch: pytest.MonkeyPatch, ) -> None: """At most *max_entries* entries, then `` +N more`` with the EXACT remainder (the true pre-cap folder total — the owning doc leaves it); the ``(1 doc)`` singular form; no `` +N more`` when every entry fits.""" rows = [(f"big/s{i}/x.md", f"S{i}", "2024-06-15") for i in range(4)] + [ (f"big/f{i:02d}.md", f"F{i}", "2024-06-15") for i in range(10) ] _patch_catalog(monkeypatch, {"S": rows}) db = cast("Session", object()) # 4 subfolders (one doc each) + 10 files − the owning f00.md = 13 # entries; 8 shown (the 4 subfolders + f01..f04) + 5 more. The # subfolder entries are source-relative (``big/s0/``) — exactly as # the model's own ls output names them. assert agent.suggested_folder_lines(db, [_doc("S", "big/f00.md")]) == [ "S/big/: big/s0/ (1 doc), big/s1/ (1 doc), big/s2/ (1 doc), big/s3/ (1 doc), " "f01.md, f02.md, f03.md, f04.md +5 more" ] # A wide enough cap shows every entry — no remainder marker. assert agent.suggested_folder_lines(db, [_doc("S", "big/f00.md")], max_entries=20) == [ "S/big/: big/s0/ (1 doc), big/s1/ (1 doc), big/s2/ (1 doc), big/s3/ (1 doc), " "f01.md, f02.md, f03.md, f04.md, f05.md, f06.md, f07.md, f08.md, f09.md" ] # The remainder is exact at a tighter cap too (3 subfolders shown — # files only follow ALL subfolders, the ls order — 10 left). assert agent.suggested_folder_lines(db, [_doc("S", "big/f00.md")], max_entries=3) == [ "S/big/: big/s0/ (1 doc), big/s1/ (1 doc), big/s2/ (1 doc) +10 more" ] def test_suggested_folder_lines_entry_order_matches_the_ls_listing( monkeypatch: pytest.MonkeyPatch, ) -> None: """The line's entry order IS the ``ls`` folder-level rendering order (pinned against :func:`group_folder_listing` itself): the direct subfolders first (path order), then the files (path order) — a mixed folder where the file names and the subfolder names interleave in path order still lists all subfolders before any file.""" rows = [ ("a/a.md", "A", "2024-06-15"), ("a/b.md", "B", "2024-06-15"), ("a/c/d.md", "D", "2024-06-15"), ] _patch_catalog(monkeypatch, {"Homelab": rows}) db = cast("Session", object()) suggested = _doc("Homelab", "a/b.md") # The expected entry order, derived from group_folder_listing itself # (subfolders → files, minus the owning doc) — the line must read # the same as the model's own ls output. sub, files, total = agent.group_folder_listing("Homelab", "a", rows, {}) assert total == 2 expected_entries = [ f"{g}/ ({c} {'doc' if c == 1 else 'docs'})" for g, c, _s in sub ] + [path.rsplit("/", 1)[-1] for _src, path, _t, _d in files if path != suggested.path] # Subfolder source-relative (``a/c/``), files by relative filename. assert expected_entries == ["a/c/ (1 doc)", "a.md"] expected_line = f"Homelab/a/: {', '.join(expected_entries)}" assert agent.suggested_folder_lines(db, [suggested]) == [expected_line] def test_suggested_folder_lines_empty_suggested_yields_no_lines( monkeypatch: pytest.MonkeyPatch, ) -> None: """No suggested docs → no lines (the caller then builds the byte-identical phase-118 prompt) — and no fetcher call at all.""" calls: list[str] = [] def _rows(db: Any, source: str) -> list[tuple[str, str, str]]: calls.append(source) return [] monkeypatch.setattr(agent, "_source_document_rows", _rows) monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {}) assert agent.suggested_folder_lines(cast("Session", object()), []) == [] assert calls == [] # ---------- the pinned drill-down templates (byte-for-byte) ---------- def test_render_ls_top_template() -> None: assert ( agent.render_ls_top( [("Deployments", 3, None), ("Homelab", 5, "Home lab notes.")] ) == "2 sources:\n\n" "Deployments — 3 documents\n" "Homelab — 5 documents\n" " Home lab notes." ) assert agent.render_ls_top([]) == "0 sources:" def test_render_folder_listing_root_template() -> None: assert ( agent.render_folder_listing( "Homelab", [("backups", 2, "Backup notes."), ("networking", 1, None)], [("Homelab", "readme.md", "Readme", "2024-06-15")], 1, ) == "Homelab — 1 documents, 2 folders:\n" "\n" " backups/ — 2 documents: Backup notes.\n" " networking/ — 1 documents\n" "\n" "source: Homelab | path: readme.md | title: Readme | date: 2024-06-15" ) def test_render_folder_listing_empty_level_is_header_alone() -> None: """A registered source with no documents: the header line alone — the old ``0 documents:`` behavior preserved in spirit.""" assert agent.render_folder_listing("Homelab", [], [], 0) == ( "Homelab — 0 documents, 0 folders:" ) def test_render_folder_listing_subfolders_only_no_blank_trailer() -> None: """Subfolders but no own files: header + blank + subfolder lines — no trailing blank line, no file section.""" assert ( agent.render_folder_listing("S", [("a", 1, None)], [], 0) == "S — 0 documents, 1 folders:\n\n a/ — 1 documents" ) def test_render_folder_listing_cap_note_only_past_fifty() -> None: """The note appears ONLY when the folder's own files outnumber the cap: 51 → 50 lines + the deterministic grep-pointer note (the ``…and 1 more…`` shape — unpluralized, the house pin); 50 → no note.""" files51 = [("S", f"f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(51)] capped = files51[:50] rendered = agent.render_folder_listing("S/big", [], capped, 51) lines = rendered.splitlines() assert lines[0] == "S/big — 51 documents, 0 folders:" assert len(lines) == 1 + 1 + 50 + 1 # header, blank, 50 lines, note assert lines[-1] == ( "…and 1 more documents in this folder — use grep (pattern) to " "find a specific one." ) files50 = [("S", f"f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(50)] rendered50 = agent.render_folder_listing("S/big", [], files50, 50) assert rendered50.splitlines()[-1] == ( "source: S | path: f049.md | title: T49 | date: 2024-06-15" ) assert "more documents" not in rendered50 # ---------- read: the canonical combined source/path form ---------- def test_read_combined_path_resolves_and_returns_full_content( monkeypatch: pytest.MonkeyPatch, ) -> None: """The combined ``source/path`` form (the model's trained shape) is the canonical identity: split at the FIRST '/', one exact lookup, the full content returned (A7-revised: never truncated) — even when the path itself carries further slashes.""" doc = _doc("Homelab", "active/container_caddy/caddy.md", "Caddy", "CADDY-CONTENT") calls: list[tuple[str, str]] = [] def _find(db: Any, source: str, path: str) -> Document | None: calls.append((source, path)) return ( doc if (source, path) == ("Homelab", "active/container_caddy/caddy.md") else None ) monkeypatch.setattr(agent, "find_document", _find) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="read", arguments={"path": "Homelab/active/container_caddy/caddy.md"}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) # First-slash split — exactly one lookup, the canonical pair. assert calls == [("Homelab", "active/container_caddy/caddy.md")] assert holder.read_docs == [doc] assert holder.tool_calls == 1 assert llm.requests[1][0][3]["content"].startswith( "Document Homelab/active/container_caddy/caddy.md:\n" "date: 2024-06-15\n" "CADDY-CONTENT" ) def test_read_bare_source_name_refused_without_db(monkeypatch: pytest.MonkeyPatch) -> None: """A bare source name (no '/') can never be a document — the no-document refusal (the argument echoed as passed), no DB lookup (NOT even the phase-72 candidate lookup — ``all_documents`` must not run either), nothing counted.""" def _boom(*_a: Any, **_k: Any) -> None: raise AssertionError( "no DB lookup (find_document or all_documents) for a bare " "source name" ) monkeypatch.setattr(agent, "find_document", _boom) monkeypatch.setattr(agent, "all_documents", _boom) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="read", arguments={"path": "Homelab"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.read_docs == [] and holder.tool_calls == 0 assert llm.requests[1][0][3]["content"].startswith( "No document at 'Homelab' — check the ls output." ) assert llm.requests[1][1] == _OFFER def test_read_unknown_path_refused_echoing_argument( monkeypatch: pytest.MonkeyPatch, ) -> None: """An unknown combined identity that matches NO indexed document's ``path`` (zero candidates — the phase-72 lookup runs, finds nothing) → today's refusal echoing the argument as passed, byte-identical — the old split-teaching refusal is gone (phase 70).""" monkeypatch.setattr(agent, "find_document", lambda db, source, path: None) monkeypatch.setattr(agent, "all_documents", lambda db: []) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="read", arguments={"path": "S/ghost.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.read_docs == [] and holder.tool_calls == 0 assert llm.requests[1][0][3]["content"].startswith( "No document at 'S/ghost.md' — check the ls output." ) assert llm.requests[1][1] == _OFFER # tools stay offered (cap bounds) # ---------- read/grep: the "did you mean …?" suggestions (phase 72, task 02) ---------- def test_find_path_candidates_exact_suffix_catalog_order( monkeypatch: pytest.MonkeyPatch, ) -> None: """The pure catalog lookup (monkeypatched ``all_documents`` — one bulk query per call): ``path`` == arg (exact) or a ``/arg`` suffix — catalog order, case-sensitive, as ``(source, path, title)`` triples; a plain substring is NOT a suffix; the result is uncapped (the :data:`~app.rag.agent.SUGGESTION_LIMIT` cap lives in the refusal). """ docs = [ _doc("A", "x.md", "Ax", "A"), _doc("A", "shared/x.md", "As", "AS"), _doc("B", "shared/x.md", "Bs", "BS"), _doc("C", "deep/shared/x.md", "Cs", "CS"), _doc("D", "X.md", "Dx", "D"), # case-sensitive: not 'x.md' _doc("E", "nosuffixx.md", "Ex", "E"), # substring, not a /suffix ] calls: list[int] = [] def _all(db: Any) -> list[Document]: calls.append(1) return docs monkeypatch.setattr(agent, "all_documents", _all) db = cast("Session", object()) # Exact bare path ('shared/x.md') plus the deeper suffix # ('deep/shared/x.md' ends with '/shared/x.md') — catalog order. assert agent.find_path_candidates(db, "shared/x.md") == [ ("A", "shared/x.md", "As"), ("B", "shared/x.md", "Bs"), ("C", "deep/shared/x.md", "Cs"), ] # 'x.md' equals A's path exactly AND suffix-matches the rest — all # four, catalog order (uncapped: the cap is the refusal's). assert agent.find_path_candidates(db, "x.md") == [ ("A", "x.md", "Ax"), ("A", "shared/x.md", "As"), ("B", "shared/x.md", "Bs"), ("C", "deep/shared/x.md", "Cs"), ] # Case-sensitive file paths: 'X.md' matches ONLY D's identically- # cased path (never the lowercase 'x.md' ones), and the plain # substring inside 'nosuffixx.md' is not a suffix. assert agent.find_path_candidates(db, "X.md") == [("D", "X.md", "Dx")] # One bulk query per call (at most one). assert len(calls) == 3 def test_read_bare_path_exact_match_gets_did_you_mean( monkeypatch: pytest.MonkeyPatch, ) -> None: """The incident shape: an unresolved ``read`` argument containing ``/`` that EXACTLY matches one indexed document's ``path`` (the bare path missing the source prefix — the harness prior) gets the ``NO_DOCUMENT_DID_YOU_MEAN`` line naming the combined identity — still a refusal: ``read_docs`` empty, nothing counted, tools stay offered.""" doc = _doc("Homelab", "active/container_caddy/caddy.md", "Caddy", "CADDY-CONTENT") def _find(db: Any, source: str, path: str) -> Document | None: return ( doc if (source, path) == ("Homelab", "active/container_caddy/caddy.md") else None ) monkeypatch.setattr(agent, "find_document", _find) monkeypatch.setattr(agent, "all_documents", lambda db: [doc]) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="read", arguments={"path": "active/container_caddy/caddy.md"}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.read_docs == [] and holder.tool_calls == 0 # a refusal assert llm.requests[1][0][3]["content"].startswith( agent.NO_DOCUMENT_DID_YOU_MEAN.format( arg="active/container_caddy/caddy.md", source="Homelab", path="active/container_caddy/caddy.md", ) ) # The rendered line, pinned byte-for-byte. assert llm.requests[1][0][3]["content"].startswith( "No document at 'active/container_caddy/caddy.md' — " "did you mean 'Homelab/active/container_caddy/caddy.md'?" ) assert llm.requests[1][1] == _OFFER # rejected → tools stay offered def test_read_bare_path_suffix_match_gets_did_you_mean( monkeypatch: pytest.MonkeyPatch, ) -> None: """The suffix form of the same teaching: a path-like argument that matches a deeper indexed path (``active/container_caddy/caddy.md`` ends with ``/container_caddy/caddy.md``) names the same combined identity.""" doc = _doc("Homelab", "active/container_caddy/caddy.md", "Caddy", "CADDY-CONTENT") def _find(db: Any, source: str, path: str) -> Document | None: return ( doc if (source, path) == ("Homelab", "active/container_caddy/caddy.md") else None ) monkeypatch.setattr(agent, "find_document", _find) monkeypatch.setattr(agent, "all_documents", lambda db: [doc]) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="read", arguments={"path": "container_caddy/caddy.md"}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.read_docs == [] and holder.tool_calls == 0 # a refusal assert llm.requests[1][0][3]["content"].startswith( "No document at 'container_caddy/caddy.md' — " "did you mean 'Homelab/active/container_caddy/caddy.md'?" ) def test_read_bare_path_two_sources_gets_one_of_suggestion( monkeypatch: pytest.MonkeyPatch, ) -> None: """The same bare path under two sources: the ``one of`` line — up to ``SUGGESTION_LIMIT`` combined identities, each single-quoted, joined with ``, `` in catalog order (A before B).""" a = _doc("A", "shared/x.md", "Ax", "A") b = _doc("B", "shared/x.md", "Bx", "B") monkeypatch.setattr(agent, "find_document", lambda db, source, path: None) monkeypatch.setattr(agent, "all_documents", lambda db: [a, b]) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece(id="call_1", name="read", arguments={"path": "shared/x.md"}) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.read_docs == [] and holder.tool_calls == 0 # a refusal assert llm.requests[1][0][3]["content"].startswith( agent.NO_DOCUMENT_DID_YOU_MEAN_MANY.format( arg="shared/x.md", candidates="'A/shared/x.md', 'B/shared/x.md'" ) ) # The rendered line, pinned byte-for-byte. assert llm.requests[1][0][3]["content"].startswith( "No document at 'shared/x.md' — did you mean one of: " "'A/shared/x.md', 'B/shared/x.md'?" ) def test_read_bare_path_four_sources_capped_at_three_suggestions( monkeypatch: pytest.MonkeyPatch, ) -> None: """Four sources sharing the same path: exactly ``SUGGESTION_LIMIT`` (3) identities are suggested — catalog order, the fourth dropped.""" docs = [_doc(s, "shared/x.md", f"{s}x", s) for s in ("A", "B", "C", "D")] monkeypatch.setattr(agent, "find_document", lambda db, source, path: None) monkeypatch.setattr(agent, "all_documents", lambda db: list(docs)) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece(id="call_1", name="read", arguments={"path": "shared/x.md"}) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "No document at 'shared/x.md' — did you mean one of: " "'A/shared/x.md', 'B/shared/x.md', 'C/shared/x.md'?" ) assert "'D/shared/x.md'" not in llm.requests[1][0][3]["content"] assert holder.read_docs == [] and holder.tool_calls == 0 # a refusal def test_read_bare_filename_without_slash_keeps_no_db_refusal( monkeypatch: pytest.MonkeyPatch, ) -> None: """The gate is the ``/`` in the argument: a bare FILENAME (no ``/`` — e.g. ``caddy.md``) is a bare name for the lookup — today's refusal byte-identical, and NO ``find_document`` / ``all_documents`` call (the same no-DB-lookup invariant as a bare source name).""" def _boom(*_a: Any, **_k: Any) -> None: raise AssertionError("no DB lookup for a bare (no '/') argument") monkeypatch.setattr(agent, "find_document", _boom) monkeypatch.setattr(agent, "all_documents", _boom) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="read", arguments={"path": "caddy.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.read_docs == [] and holder.tool_calls == 0 assert llm.requests[1][0][3]["content"].startswith( "No document at 'caddy.md' — check the ls output." ) assert llm.requests[1][1] == _OFFER def test_read_bare_path_of_suggested_doc_gets_suggestion_then_succeeds( monkeypatch: pytest.MonkeyPatch, ) -> None: """Dedupe precedence (phase 118): the in-context dedupe fires on the SPLIT pair of a document already READ — the bare path of a SUGGESTED (seeded) document (``read('app/rag/importer.py')`` with ``sample/app/rag/importer.py`` seeded) is NOT that pair, so it is not a dedupe: it gets the suggestion line naming the combined identity, and the model's next, correctly-formed call ADDS the suggested document's full text (A6: the seeds are summary blocks, not full text — a first read of a suggested document succeeds).""" seed = [_doc("sample", "app/rag/importer.py", "Importer", "IMPORTER")] def _find(db: Any, source: str, path: str) -> Document | None: return seed[0] if (source, path) == ("sample", "app/rag/importer.py") else None monkeypatch.setattr(agent, "find_document", _find) monkeypatch.setattr(agent, "all_documents", lambda db: list(seed)) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="read", arguments={"path": "app/rag/importer.py"}, ) ], [ # Round 2: the corrected call (the suggested combined # identity) — the suggested document's full text is NOT in # the prompt (only its summary is), so this read succeeds # and adds the full content. ToolCallPiece( id="call_2", name="read", arguments={"path": "sample/app/rag/importer.py"}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings(), seed_docs=seed)) # Round 1 refused (the teaching), round 2 executed (A6). assert holder.read_docs == [seed[0]] assert holder.tool_calls == 1 assert llm.requests[1][0][3]["content"].startswith( "No document at 'app/rag/importer.py' — " "did you mean 'sample/app/rag/importer.py'?" ) assert llm.requests[2][0][5]["content"].startswith( "Document sample/app/rag/importer.py:\n" "date: 2024-06-15\n" "IMPORTER" ) @pytest.mark.parametrize( ("arguments", "label"), [ ({}, "no arguments"), ({"path": ""}, "empty path"), ({"path": " "}, "blank path"), ({"path": 7}, "non-string path"), ({"path": None}, "null path"), ], ) def test_read_missing_arguments_refused( monkeypatch: pytest.MonkeyPatch, arguments: dict[str, Any], label: str ) -> None: def _boom(*_a: Any, **_k: Any) -> None: raise AssertionError(f"find_document must not be called ({label})") monkeypatch.setattr(agent, "find_document", _boom) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="read", arguments=arguments)], [StreamPiece("content", "ans")], ) 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] == _OFFER def test_reading_a_suggested_seed_doc_adds_its_full_text( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 118 (A6): a FIRST read of a suggested (seeded) document SUCCEEDS — the seed is a summary block in the prompt, not the full text, so it falls out of the dedupe set (``holder.read_docs`` only): the read goes through the existing path unchanged — the full-content result (header + date line), appended to ``holder.read_docs``, counted in ``holder.tool_calls``.""" seed = [_doc("Homelab", "kubernetes.md", "Kubernetes", "K8S-CONTENT")] monkeypatch.setattr( agent, "find_document", lambda db, source, path: seed[0] if (source, path) == ("Homelab", "kubernetes.md") else None, ) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="read", arguments={"path": "Homelab/kubernetes.md"}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings(), seed_docs=seed)) assert holder.read_docs == [seed[0]] assert holder.tool_calls == 1 assert llm.requests[1][0][3]["content"].startswith( "Document Homelab/kubernetes.md:\ndate: 2024-06-15\nK8S-CONTENT" ) assert llm.requests[1][1] == _OFFER def test_re_reading_a_suggested_doc_is_deduped(monkeypatch: pytest.MonkeyPatch) -> None: """Phase 118 (A6): once the suggested document's full text has been read into context, a SECOND read of the same combined identity is refused with the byte-identical ``ALREADY_IN_CONTEXT`` line — the dedupe set is ``holder.read_docs`` only, and the counters stay untouched. Phase 141 (task 02): the raw-arg fast path is RETIRED (the merged span's fullness needs the document's true line count, which only the resolve knows), so the re-read RESOLVES first — two lookups, not one; the observable refusal is unchanged.""" seed = [_doc("Homelab", "kubernetes.md", "Kubernetes", "K8S-CONTENT")] lookups: list[tuple[str, str]] = [] def _find(db: Any, source: str, path: str) -> Document | None: lookups.append((source, path)) return seed[0] if (source, path) == ("Homelab", "kubernetes.md") else None monkeypatch.setattr(agent, "find_document", _find) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="read", arguments={"path": "Homelab/kubernetes.md"}, ) ], [ ToolCallPiece( id="call_2", name="read", arguments={"path": "Homelab/kubernetes.md"}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings(), seed_docs=seed)) assert holder.read_docs == [seed[0]] # appended exactly once assert holder.tool_calls == 1 # the re-read counts nothing # The re-read now goes through the resolve (the fast path is # retired — the merged span (1, 1) == the whole 1-line doc makes # the claim true) — two lookups. assert lookups == [("Homelab", "kubernetes.md"), ("Homelab", "kubernetes.md")] assert llm.requests[1][0][3]["content"].startswith( "Document Homelab/kubernetes.md:\ndate: 2024-06-15\nK8S-CONTENT" ) 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] == _OFFER def test_reading_an_already_read_doc_is_deduped(monkeypatch: pytest.MonkeyPatch) -> None: """The second read of the same document (holder.read_docs) → ALREADY_IN_CONTEXT — appended once, counted once.""" doc = _doc("S", "a.md", "A", "A-CONTENT") monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="read", arguments={"path": "S/a.md"})], [ToolCallPiece(id="call_2", name="read", arguments={"path": "S/a.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.read_docs == [doc] # appended exactly once assert holder.tool_calls == 1 # the re-read counts nothing assert llm.requests[1][0][3]["content"].startswith( "Document S/a.md:\ndate: 2024-06-15\nA-CONTENT" ) 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] == _OFFER # ---------- 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))) # The read result plus the phase-106 D5 date line (no marker, no # notice). assert llm.requests[1][0][3]["content"].startswith( "Document S/big.md:\ndate: 2024-06-15\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" "date: 2024-06-15\n" + content[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=cap + 1) ) assert llm.requests[1][0][3]["content"].startswith(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 (REWRITTEN phase 131, task 01): 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 teaches the working workflow — the old dead-end is gone: ``grep`` reports line numbers, then read that part with ``offset`` / ``limit`` (BOTH parameters named in the notice itself).""" 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 find what you need — it reports line numbers — " "then read that part with `offset` (1-based first line) and " "`limit` (number of lines)." ) # Both line-range parameters are named in the notice — the model # stuck at the truncation sees the exact follow-up call. assert "`offset`" in READ_TRUNCATION_NOTICE assert "`limit`" in READ_TRUNCATION_NOTICE # The old dead-end sentence (no way to act on the line number) # is gone. assert "to locate what you need" not in READ_TRUNCATION_NOTICE 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_paths_with_suggested_seed( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 118 (A6) re-target of the phase-95 pins ("seed" → "suggested"): a SUGGESTED (seeded) document that is over the cap is read through the ordinary cap path — cut at the cap + the shared marker + the pinned notice, the truncation recorded on the holder, and the call still successful (the seed is a summary, not full text) — 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: seed if (source, path) == ("S", "seed.md") else None, ) monkeypatch.setattr(agent, "all_documents", lambda db: [seed]) # (a) Reading the (over-cap) SUGGESTED doc → the capped read: cut at # the cap, marker + pinned notice, holder entry, still counted. 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"].startswith( "Document S/seed.md:\n" "date: 2024-06-15\n" + big[:100] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=100, total=5000) ) # (argument, cap, total) — the raw argument, the cap kept, the # true length. assert holder.read_truncations == [("S/seed.md", 100, 5000)] assert holder.tool_calls == 1 and holder.read_docs == [seed] # (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"].startswith( "No document at 'S/missing.md' — check the ls output." ) assert holder2.read_truncations == [] assert holder2.tool_calls == 0 and holder2.read_docs == [] # ---------- phase 131 (task 02): the range-read execution (offset/limit) ---------- def _lines_doc(source: str, path: str, n_lines: int, prefix: str = "line ") -> Document: """A fixture document of EXACTLY *n_lines* lines (``prefix{i}``).""" content = "\n".join(f"{prefix}{i}" for i in range(1, n_lines + 1)) return _doc(source, path, path, content) def test_bad_range_args_is_pinned() -> None: """Phase 131 (task 02): the range-argument validation refusal is pinned — the contract (whole line numbers ≥ 1) with the bad values echoed (``!r`` — an absent value prints as ``None``).""" assert agent.BAD_RANGE_ARGS.format(offset=2.5, limit=None) == ( "`offset` and `limit` are whole line numbers — pass integers ≥ 1 " "(you passed offset=2.5, limit=None)." ) assert agent.BAD_RANGE_ARGS.format(offset=None, limit="10") == ( "`offset` and `limit` are whole line numbers — pass integers ≥ 1 " "(you passed offset=None, limit='10')." ) assert agent.BAD_RANGE_ARGS.format(offset=True, limit=None) == ( "`offset` and `limit` are whole line numbers — pass integers ≥ 1 " "(you passed offset=True, limit=None)." ) def test_reread_partial_is_pinned() -> None: """Phase 141 (task 02): the plain re-read teaching for a PARTIALLY-covered document is pinned — RETIRING the phase-131 ``REREAD_TRUNCATED`` line (its char-based ``{shown}`` copy is replaced by the line-based truth): the covered span(s) in line form, the RESOLVED identity, the true line count, and the working workflow (grep reports line numbers → read that part with ``offset`` + ``limit``).""" assert agent.REREAD_PARTIAL.format(covered="1–14", source="S", path="big.md", total=20) == ( "You have lines 1–14 of S/big.md in your context (20 lines " "total). To read the rest, find where your text is with grep " "(it reports line numbers), then call read with `offset` " "(1-based first line) and `limit` (number of lines)." ) # The retired char-based line no longer exists in the module. assert not hasattr(agent, "REREAD_TRUNCATED") def test_offset_past_end_is_pinned() -> None: """Phase 131 (task 02): the offset-past-end teaching is pinned — the RESOLVED identity, the true line count, the grep pointer.""" assert agent.OFFSET_PAST_END.format( source="S", path="a.md", total_lines=10, offset=11 ) == ( "S/a.md is 10 lines long — `offset` 11 is past the end. Use a " "smaller offset (grep reports the exact line numbers)." ) def test_read_range_args_plain_and_valid() -> None: """Phase 131 (task 02): the pure validator — both absent is a plain read; valid ints pass through (either one-sided is ok: ``offset`` without ``limit`` reads to the end, ``limit`` without ``offset`` counts from the start — the caller normalizes).""" assert agent._read_range_args(None, None) == (True, "", None, None) assert agent._read_range_args(1, None) == (True, "", 1, None) assert agent._read_range_args(None, 7) == (True, "", None, 7) assert agent._read_range_args(3, 42) == (True, "", 3, 42) @pytest.mark.parametrize( ("offset", "limit"), [ (2.5, None), # a float ("3", None), # a string (True, None), # a bool (an int subclass — the house guard excludes it) (0, None), # zero (-1, None), # a negative (None, 2.5), # a float limit (None, "10"), # a string limit (None, False), # a bool limit (None, 0), # a zero limit (None, -3), # a negative limit ], ) def test_read_range_args_bad_values_are_refused( offset: Any, limit: Any ) -> None: """Phase 131 (task 02): a PRESENT bad value (not a whole int ≥ 1 — the house ``isinstance(x, int) and not isinstance(x, bool)`` guard, or ``< 1``) refuses with the values echoed; the range comes back unusable (``None``/``None``).""" ok, line, got_offset, got_limit = agent._read_range_args(offset, limit) assert not ok assert got_offset is None and got_limit is None assert line == agent.BAD_RANGE_ARGS.format(offset=offset, limit=limit) # --- phase 141 (task 02): the coverage helpers (pure, no DB) --- def test_covered_spans_merges_adjacent_overlapping_and_contained() -> None: """Phase 141 (task 02): ``covered_spans`` selects the RESOLVED pair's records and MERGES them — ADJACENT spans join (``(1, 10) + (11, 20)`` → ``(1, 20)``: adjacency is continuous coverage), overlapping spans collapse to their union, a span CONTAINED in another is absorbed, and the result is sorted by first line. The input order is irrelevant; records for other documents are ignored (the keying is the resolved pair).""" records = [ ("S", "a.md", 11, 20), ("S", "a.md", 1, 10), ("T", "a.md", 5, 6), # a different source — ignored ("S", "b.md", 4, 8), # a different path — ignored ] assert agent.covered_spans(records, "S", "a.md") == [(1, 20)] assert agent.covered_spans(records, "S", "b.md") == [(4, 8)] assert agent.covered_spans(records, "S", "c.md") == [] # Overlap + containment: (2, 9) + (5, 7) + (1, 3) → (1, 9). assert agent.covered_spans( [("S", "x.md", 2, 9), ("S", "x.md", 5, 7), ("S", "x.md", 1, 3)], "S", "x.md", ) == [(1, 9)] # A single record round-trips unchanged. assert agent.covered_spans([("S", "y.md", 3, 3)], "S", "y.md") == [(3, 3)] def test_covered_spans_disjoint_spans_stay_separate() -> None: """Phase 141 (task 02): spans with a GAP between them stay separate (only adjacency/overlap merge) — the gap is uncovered, and the span order is by first line regardless of read order.""" records = [("S", "a.md", 5, 6), ("S", "a.md", 1, 2), ("S", "a.md", 1, 1)] assert agent.covered_spans(records, "S", "a.md") == [(1, 2), (5, 6)] def test_fully_covered_inclusive_per_line() -> None: """Phase 141 (task 02): ``fully_covered`` is per line and INCLUSIVE — the requested span is covered when ONE merged span starts at or before ``first`` and ends at or after ``last``; a span crossing a gap is NOT covered even when both endpoints fall in covered spans; an empty union covers nothing.""" assert agent.fully_covered([(1, 20)], 1, 20) assert agent.fully_covered([(1, 20)], 5, 7) assert agent.fully_covered([(5, 7)], 5, 7) assert not agent.fully_covered([(1, 20)], 1, 21) assert not agent.fully_covered([(1, 20)], 21, 22) assert not agent.fully_covered([], 1, 1) # The gap edge: 2-5 crosses the uncovered 3-4 between (1, 2) and # (5, 6) → NOT covered; 1-5 and 5-5 are covered within one span. assert not agent.fully_covered([(1, 2), (5, 6)], 2, 5) assert not agent.fully_covered([(1, 2), (5, 6)], 1, 5) assert agent.fully_covered([(1, 2), (5, 6)], 5, 5) assert agent.fully_covered([(1, 2), (5, 6)], 1, 2) def test_format_covered_spans_rendering() -> None: """Phase 141 (task 02): the ``{covered}`` rendering — one span → the bare ``a–b`` (the en dash), a single line → the bare ``a``, multiple merged spans → comma-joined pairs in span order.""" assert agent.format_covered_spans([(1, 14)]) == "1–14" assert agent.format_covered_spans([(7, 7)]) == "7" assert agent.format_covered_spans([(1, 2), (5, 6)]) == "1–2, 5–6" assert agent.format_covered_spans([]) == "" def test_bad_range_args_refused_before_any_lookup( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 131 (task 02): the validation refusal fires in the flow — BEFORE the dedupe and the DB resolve (a float ``offset`` with an unknown path still gets the range refusal, not the no-document line): counts in nothing, consumes a round, the tools stay offered.""" def _boom(*_a: Any, **_k: Any) -> None: raise AssertionError("no DB lookup for a bad range argument") monkeypatch.setattr(agent, "find_document", _boom) monkeypatch.setattr(agent, "all_documents", _boom) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/ghost.md", "offset": 2.5} ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.read_docs == [] and holder.tool_calls == 0 assert llm.requests[1][0][3]["content"].startswith(agent.BAD_RANGE_ARGS.format( offset=2.5, limit=None )) assert llm.requests[1][1] == _OFFER def test_bad_range_args_beats_the_dedupe( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 131 (task 02): a bad range argument on a document ALREADY IN CONTEXT is the range refusal, not ``ALREADY_IN_CONTEXT`` — the validation precedes the dedupe (a refusal either way: no count, no re-append).""" doc = _doc("S", "a.md", "A", "A-CONTENT") monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/a.md"})], [ ToolCallPiece( id="c2", name="read", arguments={"path": "S/a.md", "offset": 0} ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.read_docs == [doc] and holder.tool_calls == 1 assert llm.requests[2][0][5]["content"].startswith(agent.BAD_RANGE_ARGS.format( offset=0, limit=None )) def test_offset_past_end_refused_naming_line_count( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 131 (task 02): ``offset`` = total+1 on a 10-line doc → the pinned teaching (the true line count named, the grep pointer) — a refusal: nothing counted, the doc NOT added to the context (a corrected retry still proceeds).""" doc = _lines_doc("S", "ten.md", 10) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/ten.md", "offset": 11} ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.read_docs == [] and holder.tool_calls == 0 assert llm.requests[1][0][3]["content"].startswith(agent.OFFSET_PAST_END.format( source="S", path="ten.md", total_lines=10, offset=11 )) def test_offset_past_end_on_one_line_doc( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 131 (task 02): a 1-line doc — ``offset=2`` is past the end (the true count is 1); ``offset=1`` with an overshooting ``limit`` clamps to the single line.""" doc = _doc("S", "one.md", "One", "only") monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) # (a) offset past the end of the 1-line doc. holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/one.md", "offset": 2} ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith(agent.OFFSET_PAST_END.format( source="S", path="one.md", total_lines=1, offset=2 )) assert holder.read_docs == [] and holder.tool_calls == 0 # (b) the same doc read from line 1, limit overshoots — clamped. holder2 = AgentHolder() llm2 = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/one.md", "offset": 1, "limit": 5}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm2, holder2, _settings())) assert llm2.requests[1][0][3]["content"].startswith( "Document S/one.md:\ndate: 2024-06-15\nlines: 1-1 of 1\nonly" ) assert holder2.read_docs == [doc] and holder2.tool_calls == 1 # --- the six-row dedupe matrix (one test per row) --- def test_matrix_row1_plain_read_unread_doc_reads_full( monkeypatch: pytest.MonkeyPatch, ) -> None: """Matrix row 1: plain ``read(path)``, doc NOT in ``read_docs`` → the full read (truncate at the cap as today — the pre-phase-131 path, no ``lines:`` header line).""" doc = _doc("S", "m1.md", "M1", "one\ntwo\nthree") monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/m1.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.read_docs == [doc] and holder.tool_calls == 1 assert llm.requests[1][0][3]["content"].startswith( "Document S/m1.md:\ndate: 2024-06-15\none\ntwo\nthree" ) def test_matrix_row2_plain_read_truncated_doc_gets_reread_teaching( monkeypatch: pytest.MonkeyPatch, ) -> None: """Matrix row 2 (the stuck scenario's first hop): a plain re-read of a document whose first read TRUNCATED → the pinned ``REREAD_PARTIAL`` teaching naming the covered span — the truncated first read covers lines 1–14 (the cut counted from the actual cut string) of the 20-line doc (RE-PINED phase 141: the char-based ``REREAD_TRUNCATED`` line is retired, the ``{total}`` comes from the REAL document) — a refusal: no re-emit, no count. The raw-arg fast path is RETIRED (phase 141 — the true line count only the resolve knows), so the re-read resolves the document: TWO lookups, not one.""" cap = 100 doc = _lines_doc("S", "m2.md", 20) # 150 chars > the cap lookups: list[tuple[str, str]] = [] def _find(db: Any, source: str, path: str) -> Document | None: lookups.append((source, path)) return doc if (source, path) == ("S", "m2.md") else None monkeypatch.setattr(agent, "find_document", _find) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/m2.md"})], [ToolCallPiece(id="c2", name="read", arguments={"path": "S/m2.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) # Round 1: the truncated plain read (the stuck trigger) — the # pre-phase-131 truncation bytes, the holder tuple recorded. assert llm.requests[1][0][3]["content"].startswith( "Document S/m2.md:\ndate: 2024-06-15\n" + doc.content[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content)) ) assert holder.read_truncations == [("S/m2.md", cap, len(doc.content))] # Round 2: the plain re-read → the teaching — the covered span # (1–14: the cut's truth) named, the true line count from the # resolved document. assert llm.requests[2][0][5]["content"].startswith(agent.REREAD_PARTIAL.format( covered="1–14", source="S", path="m2.md", total=20 )) # A refusal: the counters stay at the first read's, no re-append; # the re-read RESOLVED first (the fast path is retired) — two # lookups. assert holder.read_docs == [doc] assert holder.tool_calls == 1 assert lookups == [("S", "m2.md"), ("S", "m2.md")] def test_matrix_row3_plain_read_full_doc_stays_already_in_context( monkeypatch: pytest.MonkeyPatch, ) -> None: """Matrix row 3: a plain re-read of a document read IN FULL (never truncated) → ``ALREADY_IN_CONTEXT`` — the byte-identical line, the row's observable contract unchanged by the phase-141 rework. The raw-arg fast path is RETIRED (the merged span == the whole document, but only the resolve knows ``total_lines``), so the re-read RESOLVES first — TWO lookups, not one (the fast-path-retirement pin).""" doc = _doc("S", "m3.md", "M3", "FULL-TEXT") # fits under any cap lookups: list[tuple[str, str]] = [] def _find(db: Any, source: str, path: str) -> Document | None: lookups.append((source, path)) return doc if (source, path) == ("S", "m3.md") else None monkeypatch.setattr(agent, "find_document", _find) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/m3.md"})], [ToolCallPiece(id="c2", name="read", arguments={"path": "S/m3.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[2][0][5]["content"].startswith(agent.ALREADY_IN_CONTEXT) assert holder.read_docs == [doc] and holder.tool_calls == 1 # The re-read now goes through the resolve (the merged span (1, 1) # == the whole 1-line doc makes the claim true) — two lookups. assert lookups == [("S", "m3.md"), ("S", "m3.md")] def test_matrix_row4_range_read_truncated_doc_matrix( monkeypatch: pytest.MonkeyPatch, ) -> None: """Matrix row 4 (the stuck scenario, re-pinned phase 141 — the coverage-true form of the phase-131 truncated-then-range row): the TRUNCATED plain read of this 20-line doc covers lines 1–14 (the cut counted from the actual cut string) — so a range read of lines INSIDE that span (5-7) is refused with ``ALREADY_IN_CONTEXT`` (the claim is now TRUE: those lines ARE in the prompt — the pre-phase-141 pair-set matrix refused them too, but for the wrong reason), while a range read of the part the truncated read did NOT reach (15-16 — the stuck scenario's fix) PROCEEDS with the ``lines:`` header + exactly the named lines. The doc was appended ONCE; the proceeding range read counts; the slice fits, so no new truncation tuple.""" cap = 100 doc = _lines_doc("S", "m4.md", 20) # 150 chars > the cap monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/m4.md"})], [ ToolCallPiece( id="c2", name="read", arguments={"path": "S/m4.md", "offset": 5, "limit": 3}, ) ], [ ToolCallPiece( id="c3", name="read", arguments={"path": "S/m4.md", "offset": 15, "limit": 2}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) # Round 1: the truncated plain read (covers lines 1–14). The # phase-160 trailer rides AFTER the raw result — the marker + # notice end the RAW part. content = llm.requests[1][0][3]["content"] trailer = agent._budget_note(9) assert content.endswith(trailer) assert content[: -len(trailer)].endswith( "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content)) ) # Round 2: the range read of lines INSIDE the covered span (5-7 ⊆ # 1-14) is refused — the lines ARE in the prompt, the claim is # true. assert llm.requests[2][0][5]["content"].startswith(agent.ALREADY_IN_CONTEXT) # Round 3: the range read of the part the truncated read did NOT # reach (15-16) PROCEEDS — the stuck-scenario fix — lines 15-16 # of 20, exactly. assert llm.requests[3][0][7]["content"].startswith( "Document S/m4.md:\ndate: 2024-06-15\nlines: 15-16 of 20\n" "line 15\nline 16" ) assert holder.read_docs == [doc] # appended exactly once assert holder.tool_calls == 2 # the refusal counted nothing assert holder.read_truncations == [("S/m4.md", cap, len(doc.content))] assert holder.read_coverage == [("S", "m4.md", 1, 14), ("S", "m4.md", 15, 16)] def test_matrix_row5_range_read_full_doc_stays_already_in_context( monkeypatch: pytest.MonkeyPatch, ) -> None: """Matrix row 5: a range read of a document read IN FULL (never truncated) → ``ALREADY_IN_CONTEXT`` — the range is a SUBSET of the text already in the prompt (the matrix needs the resolved pair, so the check runs after the resolve).""" doc = _lines_doc("S", "m5.md", 5) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/m5.md"})], [ ToolCallPiece( id="c2", name="read", arguments={"path": "S/m5.md", "offset": 2, "limit": 2}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[2][0][5]["content"].startswith(agent.ALREADY_IN_CONTEXT) assert holder.read_docs == [doc] and holder.tool_calls == 1 assert holder.read_truncations == [] def test_matrix_row6_range_read_unread_doc_proceeds( monkeypatch: pytest.MonkeyPatch, ) -> None: """Matrix row 6: a range read of a NOT-yet-read document PROCEEDS — the ``lines:`` header + the slice, the doc appended, the call counted (a first read, plain or range, behaves like a first read).""" doc = _lines_doc("S", "m6.md", 8) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/m6.md", "offset": 3, "limit": 2}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "Document S/m6.md:\ndate: 2024-06-15\nlines: 3-4 of 8\nline 3\nline 4" ) assert holder.read_docs == [doc] and holder.tool_calls == 1 def test_matrix_row_range_read_of_different_range_proceeds( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 02) — THE OWNER'S BUG, the matrix row that was false pre-phase: after a range read of lines 5-7, a range read of a DIFFERENT range (lines 10-11) of the SAME document PROCEEDS — the pre-phase pair-set dedupe refused it with ``ALREADY_IN_CONTEXT`` (the false "full text is in your prompt" claim that dead-ended the owner's stuck turn). A THIRD read of the already-covered lines 5-7 is refused with ``ALREADY_IN_CONTEXT`` — the claim is now TRUE (those exact lines ARE in the prompt). The doc is appended ONCE; the second range read counts; both spans are recorded in read order.""" doc = _lines_doc("S", "m7.md", 12) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/m7.md", "offset": 5, "limit": 3}, ) ], [ ToolCallPiece( id="c2", name="read", arguments={"path": "S/m7.md", "offset": 10, "limit": 2}, ) ], [ ToolCallPiece( id="c3", name="read", arguments={"path": "S/m7.md", "offset": 5, "limit": 3}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "Document S/m7.md:\ndate: 2024-06-15\nlines: 5-7 of 12\n" "line 5\nline 6\nline 7" ) # Round 2: the DIFFERENT range PROCEEDS (the owner's stuck # scenario, fixed — both ranges reach the model). assert llm.requests[2][0][5]["content"].startswith( "Document S/m7.md:\ndate: 2024-06-15\nlines: 10-11 of 12\n" "line 10\nline 11" ) # Round 3: the re-read of FULLY-covered lines 5-7 is refused — # the claim in the byte-identical line is now true. assert llm.requests[3][0][7]["content"].startswith(agent.ALREADY_IN_CONTEXT) assert holder.read_docs == [doc] # appended exactly once assert holder.tool_calls == 2 # the refusal counted nothing assert holder.read_coverage == [("S", "m7.md", 5, 7), ("S", "m7.md", 10, 11)] def test_matrix_row_disjoint_spans_gap_plain_teaching_and_merge( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 02) — the span-merge edges end to end: two DISJOINT range reads (1-2 and 5-6 of an 8-line doc) leave lines 3-4 uncovered. A PLAIN re-read gets ``REREAD_PARTIAL`` naming BOTH merged spans (``1–2, 5–6`` — the multi-span rendering, the ``{total}`` from the real doc). A range read SPANNING THE GAP (2-5) is NOT fully covered → PROCEEDS (lines 3-4 are new), and after it the merged union is (1, 6) — so a range read of 1-6 is now FULLY covered → ``ALREADY_IN_CONTEXT`` (the merge made the claim true).""" doc = _lines_doc("S", "m8.md", 8) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/m8.md", "offset": 1, "limit": 2}, ) ], [ ToolCallPiece( id="c2", name="read", arguments={"path": "S/m8.md", "offset": 5, "limit": 2}, ) ], [ToolCallPiece(id="c3", name="read", arguments={"path": "S/m8.md"})], [ ToolCallPiece( id="c4", name="read", arguments={"path": "S/m8.md", "offset": 2, "limit": 4}, ) ], [ ToolCallPiece( id="c5", name="read", arguments={"path": "S/m8.md", "offset": 1, "limit": 6}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) # Rounds 1-2: the two disjoint range reads (both proceed — first # reads of their spans). assert llm.requests[1][0][3]["content"].startswith( "Document S/m8.md:\ndate: 2024-06-15\nlines: 1-2 of 8\nline 1\nline 2" ) assert llm.requests[2][0][5]["content"].startswith( "Document S/m8.md:\ndate: 2024-06-15\nlines: 5-6 of 8\nline 5\nline 6" ) # Round 3: the plain re-read of the PARTIALLY-covered doc — BOTH # merged spans named (comma-joined, en dash), the true total. assert llm.requests[3][0][7]["content"].startswith(agent.REREAD_PARTIAL.format( covered="1–2, 5–6", source="S", path="m8.md", total=8 )) # Round 4: the range SPANNING THE GAP (2-5 crosses the uncovered # 3-4) is NOT fully covered → PROCEEDS. assert llm.requests[4][0][9]["content"].startswith( "Document S/m8.md:\ndate: 2024-06-15\nlines: 2-5 of 8\n" "line 2\nline 3\nline 4\nline 5" ) # Round 5: after the merge ((1, 2) + (2, 5) + (5, 6) → (1, 6)), # 1-6 is FULLY covered → the (now true) refusal. assert llm.requests[5][0][11]["content"].startswith(agent.ALREADY_IN_CONTEXT) assert holder.read_docs == [doc] assert holder.tool_calls == 3 # the two refusals counted nothing assert holder.read_coverage == [ ("S", "m8.md", 1, 2), ("S", "m8.md", 5, 6), ("S", "m8.md", 2, 5), ] def test_matrix_row_adjacent_spans_merge_into_full_coverage( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 02) — adjacency is continuous coverage: range reads of 1-3 and 4-6 of a 6-line doc MERGE into (1, 6) — so a range read INSIDE the merged span (2-5) is refused with ``ALREADY_IN_CONTEXT``, and a PLAIN re-read (the merged span == the whole document) is refused with the same line (the full-doc row via the merge — no single read covered the whole doc, the MERGE did).""" doc = _lines_doc("S", "m9.md", 6) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/m9.md", "offset": 1, "limit": 3}, ) ], [ ToolCallPiece( id="c2", name="read", arguments={"path": "S/m9.md", "offset": 4, "limit": 3}, ) ], [ ToolCallPiece( id="c3", name="read", arguments={"path": "S/m9.md", "offset": 2, "limit": 4}, ) ], [ToolCallPiece(id="c4", name="read", arguments={"path": "S/m9.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) # Rounds 1-2: the adjacent range reads (both proceed). assert llm.requests[1][0][3]["content"].startswith( "Document S/m9.md:\ndate: 2024-06-15\nlines: 1-3 of 6\n" ) assert llm.requests[2][0][5]["content"].startswith( "Document S/m9.md:\ndate: 2024-06-15\nlines: 4-6 of 6\n" ) # Round 3: 2-5 sits inside the merged (1, 6) → refused. assert llm.requests[3][0][7]["content"].startswith(agent.ALREADY_IN_CONTEXT) # Round 4: the plain re-read — the merged span IS the whole doc # (1, 6) → the full-doc row, the same line. assert llm.requests[4][0][9]["content"].startswith(agent.ALREADY_IN_CONTEXT) assert holder.read_docs == [doc] assert holder.tool_calls == 2 assert holder.read_coverage == [("S", "m9.md", 1, 3), ("S", "m9.md", 4, 6)] def test_scenario_adjacent_reads_cover_half_doc_teaching_and_rest_proceed( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 03) — the adjacent-span edge as a SCENARIO (the task 02 row pins, end to end over a 200-line doc): two ADJACENT range reads — 1-60 then 61-100 — together cover lines 1–100 of the 200-line document (the merge: adjacent spans join into one). A PLAIN re-read gets ``REREAD_PARTIAL`` naming the MERGED span (``1–100`` — one span, not two: the merge collapsed the adjacency) and the true total (200); a read of the REST (``offset=101`` — no ``limit``, to the end) PROCEEDS with lines 101-200 (the owner's stuck-scenario fix on a document whose first half was read in two adjacent hops: the covered span stops at line 100, the rest is not in context).""" doc = _lines_doc("S", "half.md", 200) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/half.md", "offset": 1, "limit": 60}, ) ], [ ToolCallPiece( id="c2", name="read", arguments={"path": "S/half.md", "offset": 61, "limit": 40}, ) ], [ToolCallPiece(id="c3", name="read", arguments={"path": "S/half.md"})], [ ToolCallPiece( id="c4", name="read", arguments={"path": "S/half.md", "offset": 101}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) # Rounds 1-2: the adjacent range reads (both proceed — first # reads of their spans). assert llm.requests[1][0][3]["content"].startswith( "Document S/half.md:\ndate: 2024-06-15\nlines: 1-60 of 200\n" ) assert llm.requests[2][0][5]["content"].startswith( "Document S/half.md:\ndate: 2024-06-15\nlines: 61-100 of 200\n" ) # Round 3: the plain re-read — the MERGED span named as one # (adjacent spans join: 1–100), the true total from the resolved # doc. assert llm.requests[3][0][7]["content"].startswith(agent.REREAD_PARTIAL.format( covered="1–100", source="S", path="half.md", total=200 )) # Round 4: the REST of the document (offset=101, no limit → to # the end) PROCEEDS — the covered span stops at line 100. assert llm.requests[4][0][9]["content"].startswith( "Document S/half.md:\ndate: 2024-06-15\nlines: 101-200 of 200\n" + "\n".join(f"line {i}" for i in range(101, 201)) ) assert holder.read_docs == [doc] assert holder.tool_calls == 3 # the teaching refusal counted nothing assert holder.read_coverage == [ ("S", "half.md", 1, 60), ("S", "half.md", 61, 100), ("S", "half.md", 101, 200), ] # --- the slice math + the result shape --- def test_range_read_offset_one_no_limit_reads_whole_doc( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 131 (task 02): ``offset=1`` without ``limit`` reads to the end of the document — the true extent ``1-N of N``, the whole content (the slice, not the plain-read shape).""" doc = _doc("S", "r1.md", "R1", "a\nb\nc") monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/r1.md", "offset": 1} ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "Document S/r1.md:\ndate: 2024-06-15\nlines: 1-3 of 3\na\nb\nc" ) assert holder.read_docs == [doc] and holder.tool_calls == 1 def test_range_read_offset_at_total_reads_last_line( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 131 (task 02): ``offset`` = the last line number → exactly the last line (``N-N of N``).""" doc = _doc("S", "r2.md", "R2", "a\nb\nc") monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/r2.md", "offset": 3, "limit": 5}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "Document S/r2.md:\ndate: 2024-06-15\nlines: 3-3 of 3\nc" ) def test_range_read_limit_overshoot_clamps_to_total( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 131 (task 02): an overshooting ``limit`` clamps ``end`` to the last line (reads to the end, never past it).""" doc = _doc("S", "r3.md", "R3", "a\nb\nc") monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/r3.md", "offset": 2, "limit": 10}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "Document S/r3.md:\ndate: 2024-06-15\nlines: 2-3 of 3\nb\nc" ) def test_range_read_limit_without_offset_counts_from_start( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 131 (task 02): ``limit`` WITHOUT ``offset`` is a line count from the start of the document (offset = 1 — the helper's documented one-sided semantics).""" doc = _doc("S", "r4.md", "R4", "a\nb\nc") monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/r4.md", "limit": 2} ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "Document S/r4.md:\ndate: 2024-06-15\nlines: 1-2 of 3\na\nb" ) def test_range_read_image_doc_carries_marker_after_lines_line( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 131 (task 02): a range read of a standalone-IMAGE document carries the :data:`agent.IMAGE_DOC_MARKER` line exactly where the plain read puts it — after the header lines (here, after ``lines:``).""" doc = _doc("S", "img.png", "Img", "IMG-DESC-OF-LINE-1\nline-2-text") doc.is_image = True monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/img.png", "offset": 2, "limit": 1}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "Document S/img.png:\ndate: 2024-06-15\nlines: 2-2 of 2\n" f"{agent.IMAGE_DOC_MARKER}\n" "line-2-text" ) assert holder.read_docs == [doc] and holder.tool_calls == 1 def test_range_read_slice_over_cap_truncates_with_slice_length_total( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 131 (task 02): the cap applies to the SLICE — an over-cap slice is cut at the cap + the shared marker + the pinned notice (``{total}`` = the SLICE's length), and the holder tuple's ``chars_total`` is the slice's length, NOT the document's (the loop still yields one ToolResultPiece for it).""" cap = 20 lines = [f"L{i:02d}" + "x" * 30 for i in range(1, 11)] # 10 lines × 33 chars content = "\n".join(lines) doc = _doc("S", "cap.md", "Cap", content) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/cap.md", "offset": 5, "limit": 6}, ) ], [StreamPiece("content", "ans")], ) out = asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) # The slice: lines 5-10 (6 × 33 chars + 5 newlines = 203 > cap). slice_text = "\n".join(lines[4:10]) assert len(slice_text) == 203 assert len(content) == 339 # the document is much bigger assert llm.requests[1][0][3]["content"].startswith( "Document S/cap.md:\ndate: 2024-06-15\nlines: 5-10 of 10\n" + slice_text[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text)) ) # The tuple: the argument, the cap kept, the SLICE's length (203, # not the document's 339). assert holder.read_truncations == [("S/cap.md", cap, 203)] assert holder.read_docs == [doc] and holder.tool_calls == 1 # The loop yields the ToolResultPiece with the slice's total. pieces = [p for p in out if isinstance(p, ToolResultPiece)] assert len(pieces) == 1 assert pieces[0].argument == "S/cap.md" assert pieces[0].chars_shown == cap assert pieces[0].chars_total == 203 def test_range_read_truncated_then_plain_reread_teaching_then_range_proceeds( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 131 (task 02), re-pinned phase 141: a TRUNCATED range read (offset=1, no limit — the whole 339-char doc under a 20-char cap) records the slice tuple; a plain re-read then gets ``REREAD_PARTIAL`` naming the covered span — LINE 1 ONLY (the cut of the truncated range read covers its one visible line; the single-line rendering is the bare ``a``) with the true line count (RE-PINED: the char-based ``REREAD_TRUNCATED`` line is retired); a subsequent range read still PROCEEDS (the truncated- then-range row — the requested 5-6 is not covered).""" cap = 20 lines = [f"L{i:02d}" + "x" * 30 for i in range(1, 11)] content = "\n".join(lines) doc = _doc("S", "t.md", "T", content) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/t.md", "offset": 1} ) ], [ToolCallPiece(id="c2", name="read", arguments={"path": "S/t.md"})], [ ToolCallPiece( id="c3", name="read", arguments={"path": "S/t.md", "offset": 5, "limit": 2}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) # Round 1: the truncated range read (the whole doc, offset=1) — # The slice is the whole doc here (339 chars), the tuple's total # is the slice's length. The phase-160 trailer rides AFTER the # raw result — the marker + notice end the RAW part. assert llm.requests[1][0][3]["content"].startswith( "Document S/t.md:\ndate: 2024-06-15\nlines: 1-10 of 10\n" ) content = llm.requests[1][0][3]["content"] trailer = agent._budget_note(9) assert content.endswith(trailer) assert content[: -len(trailer)].endswith( "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=339) ) # Round 2: the plain re-read → the teaching — the covered span # (line 1 only: the cut string's splitlines() is one line) named, # the true line count from the resolved document. assert llm.requests[2][0][5]["content"].startswith(agent.REREAD_PARTIAL.format( covered="1", source="S", path="t.md", total=10 )) # Round 3: the range read of the truncated doc PROCEEDS — lines 5-6 # (66 + 1 = 67 chars > the cap → truncated again; the tuple's total # is the slice's length, the document's is irrelevant). slice_text = "\n".join(lines[4:6]) assert llm.requests[3][0][7]["content"].startswith( "Document S/t.md:\ndate: 2024-06-15\nlines: 5-6 of 10\n" + slice_text[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text)) ) assert holder.read_truncations == [("S/t.md", cap, 339), ("S/t.md", cap, 67)] assert holder.read_docs == [doc] # appended exactly once, still assert holder.tool_calls == 2 # the refusal counted nothing def test_plain_read_after_range_read_gets_partial_teaching( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 131 (task 02), re-pinned phase 141 (task 02): a range read covers ONLY its own span (lines 1-2 of a 3-line doc), so a later PLAIN read of the document is the PARTIAL row — ``REREAD_PARTIAL`` naming the covered span (``1–2``) and the true total (RE-PINED: the pre-phase-141 ``ALREADY_IN_CONTEXT`` line claimed the FULL text was in the prompt — false, only 2 of 3 lines were; the false full-text claim is gone in BOTH refusal directions). The doc is still appended to ``read_docs`` ONCE (the read-once bookkeeping is unchanged).""" doc = _doc("S", "p.md", "P", "a\nb\nc") monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/p.md", "offset": 1, "limit": 2}, ) ], [ToolCallPiece(id="c2", name="read", arguments={"path": "S/p.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "Document S/p.md:\ndate: 2024-06-15\nlines: 1-2 of 3\na\nb" ) assert llm.requests[2][0][5]["content"].startswith(agent.REREAD_PARTIAL.format( covered="1–2", source="S", path="p.md", total=3 )) assert holder.read_docs == [doc] and holder.tool_calls == 1 # --- phase 141 (task 01): coverage tracking (which lines a read put # --- in the model's context — the state task 02's dedupe matrix # --- decides on) --- def test_coverage_plain_read_under_cap_records_full_span( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 01): a plain read at or under the cap covers the WHOLE document — one span ``(source, path, 1, total_lines)`` keyed by the RESOLVED pair (the same pair ``read_docs`` keys on). The existing signals stay byte-identical (unchanged meaning).""" doc = _lines_doc("S", "cv1.md", 4) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/cv1.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "Document S/cv1.md:\ndate: 2024-06-15\nline 1\nline 2\nline 3\nline 4" ) assert holder.read_coverage == [("S", "cv1.md", 1, 4)] assert holder.read_docs == [doc] and holder.tool_calls == 1 assert holder.read_truncations == [] def test_coverage_truncated_plain_read_records_cut_span( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 01): a truncated plain read covers from line 1 to the last line visible in the ACTUAL cut string — ``len( content[:cap].splitlines())`` counted from the cut (the char cap is never re-derived into lines arithmetically). The 150-char 20-line fixture under a 100-char cap cuts mid-line 14 (its visible prefix ``li`` is the last line the model saw), so the span is ``(1, 14)``.""" cap = 100 doc = _lines_doc("S", "cv2.md", 20) # 150 chars > the cap monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/cv2.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) assert len(doc.content[:cap].splitlines()) == 14 # the cut's truth assert holder.read_coverage == [("S", "cv2.md", 1, 14)] assert holder.read_truncations == [("S/cv2.md", cap, len(doc.content))] assert holder.read_docs == [doc] and holder.tool_calls == 1 def test_coverage_truncated_plain_read_cut_at_line_boundary( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 01): the boundary sub-case of the same counted-from-truth rule — a cap that lands EXACTLY on a line boundary (the cut ends with the line's newline): the last visible line is complete and the span ends there. ``aa\nbb\ncc\ndd`` under a 6-char cap cuts ``aa\nbb\n`` → the span is ``(1, 2)``.""" cap = 6 doc = _doc("S", "cv3.md", "Cv3", "aa\nbb\ncc\ndd") # 11 chars > cap monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/cv3.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) assert llm.requests[1][0][3]["content"].startswith( "Document S/cv3.md:\ndate: 2024-06-15\n" + doc.content[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content)) ) assert holder.read_coverage == [("S", "cv3.md", 1, 2)] assert holder.read_truncations == [("S/cv3.md", cap, 11)] def test_coverage_range_read_under_cap_records_true_extent( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 01): a range read at or under the cap covers exactly the TRUE extent the ``lines:`` header reports — ``(first, end)``. An overshooting ``limit`` clamps ``end`` to the last line (the span never goes past it); ``offset`` without ``limit`` reads to the end; ``limit`` without ``offset`` counts from line 1.""" doc = _doc("S", "cv4.md", "Cv4", "a\nb\nc\nd") monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) # (a) offset=2 + an overshooting limit → the clamped extent 2-4. holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/cv4.md", "offset": 2, "limit": 10}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "Document S/cv4.md:\ndate: 2024-06-15\nlines: 2-4 of 4\nb\nc\nd" ) assert holder.read_coverage == [("S", "cv4.md", 2, 4)] # (b) offset without limit → reads to the end (2-3 of 3). doc3 = _doc("S", "cv4b.md", "Cv4b", "a\nb\nc") monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc3) holder2 = AgentHolder() llm2 = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/cv4b.md", "offset": 2} ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm2, holder2, _settings())) assert holder2.read_coverage == [("S", "cv4b.md", 2, 3)] # (c) limit without offset → a line count from the start (1-2 of 4 # on the 4-line doc). monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder3 = AgentHolder() llm3 = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/cv4.md", "limit": 2} ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm3, holder3, _settings())) assert llm3.requests[1][0][3]["content"].startswith( "Document S/cv4.md:\ndate: 2024-06-15\nlines: 1-2 of 4\na\nb" ) assert holder3.read_coverage == [("S", "cv4.md", 1, 2)] def test_coverage_truncated_range_read_records_cut_extent( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 01): a truncated RANGE read covers from ``first`` to the last line visible in the cut SLICE — ``first - 1 + len(slice[:cap].splitlines())``, clamped to ``end`` (the same counted-from-truth rule as the plain read — ``slice_text[:cap]`` is the actual cut string the model saw). The 203-char lines-5-10 slice under a 20-char cap cuts mid-line-5 (``L05`` + 17 ``x``), so the span is ``(5, 5)``.""" cap = 20 lines = [f"L{i:02d}" + "x" * 30 for i in range(1, 11)] # 10 × 33 chars doc = _doc("S", "cv5.md", "Cv5", "\n".join(lines)) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/cv5.md", "offset": 5, "limit": 6}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) slice_text = "\n".join(lines[4:10]) # 203 chars > the cap assert len(slice_text[:cap].splitlines()) == 1 # only line 5 is visible assert llm.requests[1][0][3]["content"].startswith( "Document S/cv5.md:\ndate: 2024-06-15\nlines: 5-10 of 10\n" + slice_text[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text)) ) assert holder.read_coverage == [("S", "cv5.md", 5, 5)] assert holder.read_truncations == [("S/cv5.md", cap, len(slice_text))] assert holder.read_docs == [doc] and holder.tool_calls == 1 def test_coverage_truncated_range_read_cut_at_line_boundary( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 01): the boundary sub-case for the SLICE cut — a cap that lands exactly on a line boundary (the cut ends with the line's newline): the span ends at that complete line. The 8-char lines-1-3 slice under a 5-char cap cuts ``aa\nbb\n`` → the span is ``(1, 2)`` (``end`` = 3, the clamp not reached).""" cap = 5 doc = _doc("S", "cv6.md", "Cv6", "aa\nbb\ncc") # 8 chars > cap monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/cv6.md", "offset": 1, "limit": 3}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) assert llm.requests[1][0][3]["content"].startswith( "Document S/cv6.md:\ndate: 2024-06-15\nlines: 1-3 of 3\n" + doc.content[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content)) ) assert holder.read_coverage == [("S", "cv6.md", 1, 2)] assert holder.read_truncations == [("S/cv6.md", cap, 8)] def test_coverage_spans_accumulate_in_read_order( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 01): each SUCCESSFUL read appends its own span, in read order — a truncated plain read (span 1) followed by the range read that proceeds (phase-131 row 4) leaves BOTH spans, keyed by the same resolved pair.""" cap = 100 doc = _lines_doc("S", "cv7.md", 20) # 150 chars > the cap monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/cv7.md"})], [ ToolCallPiece( id="c2", name="read", arguments={"path": "S/cv7.md", "offset": 15, "limit": 2}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) # Round 2 PROCEEDED (the truncated-then-range row) — lines 15-16 # (15 chars — under the cap, no second truncation). assert llm.requests[2][0][5]["content"].startswith( "Document S/cv7.md:\ndate: 2024-06-15\nlines: 15-16 of 20\n" "line 15\nline 16" ) assert holder.read_coverage == [("S", "cv7.md", 1, 14), ("S", "cv7.md", 15, 16)] assert holder.tool_calls == 2 assert len(holder.read_truncations) == 1 def test_coverage_spans_keyed_by_the_resolved_pair( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 01): the span is keyed by the RESOLVED ``(source, path)`` pair — the document's own identity (the ``read_docs`` keying; by the ``_resolve_path`` first-slash contract the raw argument's split and the resolved pair agree — the same pair ``read_truncations`` keying recovers from the raw argument). A nested path (slashes in the path part) keeps the pair intact: the canonical ``source/path`` argument read records the document's ``(source, path)``, not a mangled split.""" doc = _doc("S", "dir/nested/file.md", "File", "one\ntwo") monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/dir/nested/file.md", "offset": 1, "limit": 1}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "Document S/dir/nested/file.md:\ndate: 2024-06-15\n" "lines: 1-1 of 2\none" ) assert holder.read_coverage == [("S", "dir/nested/file.md", 1, 1)] assert holder.read_docs == [doc] and holder.tool_calls == 1 # --- phase 141 (task 01): refusals record NOTHING --- def test_coverage_bad_range_args_records_nothing( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 01): a bad range argument (the pre-dedupe validation refusal) records NO span — a refused read counts in nothing, including the coverage.""" doc = _lines_doc("S", "cvr1.md", 5) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/cvr1.md", "offset": 2.5}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith(agent.BAD_RANGE_ARGS.format( offset=2.5, limit=None )) assert holder.read_coverage == [] assert holder.read_docs == [] and holder.tool_calls == 0 def test_coverage_unknown_doc_records_nothing( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 01): a read that resolves to NO document (the no-document refusal) records NO span.""" monkeypatch.setattr(agent, "find_document", lambda db, source, path: None) monkeypatch.setattr(agent, "all_documents", lambda db: []) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/ghost.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "No document at 'S/ghost.md' — check the ls output." ) assert holder.read_coverage == [] assert holder.read_docs == [] and holder.tool_calls == 0 def test_coverage_offset_past_end_records_nothing( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 01): an ``offset`` past the end (the post-resolve teaching refusal) records NO span — the document is NOT added to the context, so a corrected retry still proceeds""" doc = _lines_doc("S", "cvr3.md", 10) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="read", arguments={"path": "S/cvr3.md", "offset": 11} ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith(agent.OFFSET_PAST_END.format( source="S", path="cvr3.md", total_lines=10, offset=11 )) assert holder.read_coverage == [] assert holder.read_docs == [] and holder.tool_calls == 0 def test_coverage_plain_reread_of_full_doc_records_nothing_new( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 01): the plain re-read of a read-in-full doc (``ALREADY_IN_CONTEXT`` — the raw-arg fast path) appends NOTHING — the list still holds the first read's span and nothing else.""" doc = _doc("S", "cvr4.md", "Cv4", "FULL-TEXT") # one line, under any cap monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/cvr4.md"})], [ToolCallPiece(id="c2", name="read", arguments={"path": "S/cvr4.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith("Document S/cvr4.md:") assert llm.requests[2][0][5]["content"].startswith(agent.ALREADY_IN_CONTEXT) assert holder.read_coverage == [("S", "cvr4.md", 1, 1)] # still ONE span assert holder.read_docs == [doc] and holder.tool_calls == 1 def test_coverage_plain_reread_of_truncated_doc_records_nothing_new( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 01; the refusal re-pinned task 02): the plain re-read of a TRUNCATED doc (``REREAD_PARTIAL`` — the partial- coverage row) appends NOTHING — the list still holds the first read's cut span only.""" cap = 100 doc = _lines_doc("S", "cvr5.md", 20) # 150 chars > the cap monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/cvr5.md"})], [ToolCallPiece(id="c2", name="read", arguments={"path": "S/cvr5.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings(read_max_chars=cap))) assert llm.requests[2][0][5]["content"].startswith(agent.REREAD_PARTIAL.format( covered="1–14", source="S", path="cvr5.md", total=20 )) assert holder.read_coverage == [("S", "cvr5.md", 1, 14)] # still ONE span assert holder.read_docs == [doc] and holder.tool_calls == 1 def test_coverage_range_reread_of_full_doc_records_nothing_new( monkeypatch: pytest.MonkeyPatch, ) -> None: """Phase 141 (task 01): the range read of a read-in-full doc (``ALREADY_IN_CONTEXT`` — the post-resolve row) appends NOTHING — the list still holds the first read's full span only.""" doc = _lines_doc("S", "cvr6.md", 5) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/cvr6.md"})], [ ToolCallPiece( id="c2", name="read", arguments={"path": "S/cvr6.md", "offset": 2, "limit": 2}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[2][0][5]["content"].startswith(agent.ALREADY_IN_CONTEXT) assert holder.read_coverage == [("S", "cvr6.md", 1, 5)] # still ONE span assert holder.read_docs == [doc] and holder.tool_calls == 1 def test_unknown_tool_name_refused(monkeypatch: pytest.MonkeyPatch) -> None: holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="delete_universe", arguments={"x": 1})], [StreamPiece("content", "ans")], ) 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] == _OFFER # rejected → tools stay offered # ---------- phase 145 (task 01): the origin/branch lines in the read result ---------- #: The fixture git row's URL (basename ``skills`` = the fixture source #: name) and the four origin outcomes the read-branch matrix runs: #: git row matched (branch NULL), git row + pinned branch, local source #: (no git row), two rows sharing the basename (ambiguous — the source #: name is ``repo`` for the last one). _GIT_URL = "https://github.com/owner/skills" _GIT_ROWS = [GitSource(url=_GIT_URL, kind="git")] _GIT_PINNED_ROWS = [GitSource(url=_GIT_URL, kind="git", branch="release/1.2")] _LOCAL_ROWS = [GitSource(url="/srv/skills", kind="local", path="/srv/skills")] _AMBIGUOUS_ROWS = [ GitSource(url="https://a.example/org/repo.git", kind="git"), GitSource(url="git@b.example:other/repo.git", kind="git"), ] def _git_rows_factory(rows: list[GitSource]) -> Callable[[], Session]: """A db factory whose mock session answers the resolver's query with the git rows of *rows* (like the real ``kind = 'git'`` WHERE clause — the seeded local rows are filtered out, proving they are never candidates): the REAL ``origin_for_source`` runs in the read branch, no database.""" git_rows = [row for row in rows if row.kind == "git"] def _factory() -> Session: 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=git_rows))) return mock return _factory def _one_read( monkeypatch: pytest.MonkeyPatch, doc: Document, rows: list[GitSource], arguments: dict[str, Any], read_max_chars: int | None = None, ) -> str: """One scripted read turn against the REAL resolver; returns the exact tool-result string (the successful-read bookkeeping is asserted — the matrix asserts the bytes).""" monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments=arguments)], [StreamPiece("content", "ans")], ) kwargs: dict[str, Any] = {"read_max_chars": read_max_chars} if read_max_chars else {} asyncio.run(_run(llm, holder, _settings(**kwargs), db_factory=_git_rows_factory(rows))) assert holder.read_docs == [doc] and holder.tool_calls == 1 # Phase 160 (task 01): the trailer rides the read result too — # round 1 of the default 10-round budget reads 9 left. This # helper returns the RAW result (its contract), with the trailer # stripped after being asserted present. content = llm.requests[1][0][3]["content"] assert content.endswith(agent._budget_note(9)) return content.removesuffix(agent._budget_note(9)) def test_origin_lines_plain_read_under_cap( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """Shape 1 (plain read at or under the cap): the origin block rides IMMEDIATELY AFTER the ``date:`` line — ``Document …: / date: / origin: / branch: / content`` — in all four origin outcomes; the first line (the mock's ``_READ_RESULT_PREFIX`` contract) is untouched, and the local/ambiguous results are byte-identical to pre-phase (the ambiguity still warns).""" doc = _doc("skills", "docs/a.md", "A", "A-CONTENT") plain = {"path": "skills/docs/a.md"} result = _one_read(monkeypatch, doc, _GIT_ROWS, plain) assert result == ( "Document skills/docs/a.md:\n" "date: 2024-06-15\n" f"origin: {_GIT_URL}\n" "A-CONTENT" ) # The E2E mock's header contract: first line, byte-identical. assert result.startswith("Document ") assert result.splitlines()[0] == "Document skills/docs/a.md:" result = _one_read(monkeypatch, doc, _GIT_PINNED_ROWS, plain) assert result == ( "Document skills/docs/a.md:\n" "date: 2024-06-15\n" f"origin: {_GIT_URL}\n" "branch: release/1.2\n" "A-CONTENT" ) result = _one_read(monkeypatch, doc, _LOCAL_ROWS, plain) assert result == "Document skills/docs/a.md:\ndate: 2024-06-15\nA-CONTENT" repo_doc = _doc("repo", "docs/a.md", "A", "A-CONTENT") caplog.clear() result = _one_read(monkeypatch, repo_doc, _AMBIGUOUS_ROWS, {"path": "repo/docs/a.md"}) assert result == "Document repo/docs/a.md:\ndate: 2024-06-15\nA-CONTENT" # The ambiguity is logged at WARNING (the agent's INFO per-turn log # lines are captured too — filter to the resolver's records). warnings = [ r for r in caplog.records if r.levelname == "WARNING" and r.name == "app.rag.git_sources" ] assert len(warnings) == 1 assert "repo" in warnings[0].message def test_origin_lines_truncated_plain_read( monkeypatch: pytest.MonkeyPatch, ) -> None: """Shape 2 (plain read cut at the cap): the origin block sits at the same position (after ``date:``, before the cut content), the truncation marker + the pinned notice unchanged, the holder truncation tuple unchanged (the ``read_truncations`` signal is untouched).""" cap = 20 content = "x" * (cap + 1) doc = _doc("skills", "big.md", "Big", content) plain = {"path": "skills/big.md"} result = _one_read(monkeypatch, doc, _GIT_ROWS, plain, read_max_chars=cap) assert result == ( "Document skills/big.md:\n" "date: 2024-06-15\n" f"origin: {_GIT_URL}\n" + content[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content)) ) result = _one_read(monkeypatch, doc, _GIT_PINNED_ROWS, plain, read_max_chars=cap) assert result == ( "Document skills/big.md:\n" "date: 2024-06-15\n" f"origin: {_GIT_URL}\n" "branch: release/1.2\n" + content[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content)) ) result = _one_read(monkeypatch, doc, _LOCAL_ROWS, plain, read_max_chars=cap) assert result == ( "Document skills/big.md:\n" "date: 2024-06-15\n" + content[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content)) ) repo_doc = _doc("repo", "big.md", "Big", content) result = _one_read( monkeypatch, repo_doc, _AMBIGUOUS_ROWS, {"path": "repo/big.md"}, read_max_chars=cap ) assert result == ( "Document repo/big.md:\n" "date: 2024-06-15\n" + content[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content)) ) def test_origin_lines_range_read_under_cap( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """Shape 3 (range read at or under the cap): the origin block rides between the ``date:`` line and the ``lines:`` header (the same position as in the plain shapes); the local/ambiguous results stay byte-identical to pre-phase.""" doc = _doc("skills", "b.md", "B", "one\ntwo\nthree\nfour\nfive") ranged = {"path": "skills/b.md", "offset": 2, "limit": 2} result = _one_read(monkeypatch, doc, _GIT_ROWS, ranged) assert result == ( "Document skills/b.md:\n" "date: 2024-06-15\n" f"origin: {_GIT_URL}\n" "lines: 2-3 of 5\n" "two\nthree" ) result = _one_read(monkeypatch, doc, _GIT_PINNED_ROWS, ranged) assert result == ( "Document skills/b.md:\n" "date: 2024-06-15\n" f"origin: {_GIT_URL}\n" "branch: release/1.2\n" "lines: 2-3 of 5\n" "two\nthree" ) result = _one_read(monkeypatch, doc, _LOCAL_ROWS, ranged) assert result == "Document skills/b.md:\ndate: 2024-06-15\nlines: 2-3 of 5\ntwo\nthree" repo_doc = _doc("repo", "b.md", "B", "one\ntwo\nthree\nfour\nfive") caplog.clear() result = _one_read( monkeypatch, repo_doc, _AMBIGUOUS_ROWS, {"path": "repo/b.md", "offset": 2, "limit": 2} ) assert result == "Document repo/b.md:\ndate: 2024-06-15\nlines: 2-3 of 5\ntwo\nthree" # The ambiguity is logged at WARNING (the agent's INFO per-turn log # lines are captured too — filter to the resolver's records). warnings = [ r for r in caplog.records if r.levelname == "WARNING" and r.name == "app.rag.git_sources" ] assert len(warnings) == 1 assert "repo" in warnings[0].message def test_origin_lines_truncated_range_read( monkeypatch: pytest.MonkeyPatch, ) -> None: """Shape 4 (range read whose SLICE exceeds the cap): the origin block at the same position; the cap on the slice + the shared marker + the pinned notice (``{total}`` = the SLICE's length) all unchanged.""" cap = 40 doc = _doc("skills", "c.md", "C", "a" * 30 + "\n" + "b" * 30 + "\n" + "c" * 30) slice_text = "a" * 30 + "\n" + "b" * 30 # the requested lines 1-2 (61 chars) ranged = {"path": "skills/c.md", "offset": 1, "limit": 2} result = _one_read(monkeypatch, doc, _GIT_ROWS, ranged, read_max_chars=cap) assert result == ( "Document skills/c.md:\n" "date: 2024-06-15\n" f"origin: {_GIT_URL}\n" "lines: 1-2 of 3\n" + slice_text[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text)) ) result = _one_read(monkeypatch, doc, _GIT_PINNED_ROWS, ranged, read_max_chars=cap) assert result == ( "Document skills/c.md:\n" "date: 2024-06-15\n" f"origin: {_GIT_URL}\n" "branch: release/1.2\n" "lines: 1-2 of 3\n" + slice_text[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text)) ) result = _one_read(monkeypatch, doc, _LOCAL_ROWS, ranged, read_max_chars=cap) assert result == ( "Document skills/c.md:\n" "date: 2024-06-15\n" "lines: 1-2 of 3\n" + slice_text[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text)) ) repo_doc = _doc("repo", "c.md", "C", "a" * 30 + "\n" + "b" * 30 + "\n" + "c" * 30) result = _one_read( monkeypatch, repo_doc, _AMBIGUOUS_ROWS, {"path": "repo/c.md", "offset": 1, "limit": 2}, read_max_chars=cap, ) assert result == ( "Document repo/c.md:\n" "date: 2024-06-15\n" "lines: 1-2 of 3\n" + slice_text[:cap] + "\n" + TRUNCATION_MARKER + "\n" + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text)) ) def test_origin_lines_image_doc_keeps_marker_position( monkeypatch: pytest.MonkeyPatch, ) -> None: """The image-doc shape keeps its phase-122 pin: the marker stays IMMEDIATELY ABOVE the content, and the origin lines ride ABOVE the marker (``Document …: / date: / origin: / branch: / marker / content``); a local image doc is byte-identical to pre-phase.""" doc = _doc("skills", "img.png", "Img", "IMG-DESC") doc.is_image = True plain = {"path": "skills/img.png"} result = _one_read(monkeypatch, doc, _GIT_PINNED_ROWS, plain) assert result == ( "Document skills/img.png:\n" "date: 2024-06-15\n" f"origin: {_GIT_URL}\n" "branch: release/1.2\n" f"{agent.IMAGE_DOC_MARKER}\n" "IMG-DESC" ) doc = _doc("skills", "img.png", "Img", "IMG-DESC") doc.is_image = True result = _one_read(monkeypatch, doc, _LOCAL_ROWS, plain) assert result == ( "Document skills/img.png:\ndate: 2024-06-15\n" f"{agent.IMAGE_DOC_MARKER}\n" "IMG-DESC" ) # ---------- grep (the phase-68 A5 contract under the new name) ---------- def test_grep_document_folds_accents_both_ways() -> None: """Phase 162 (LOCKED A3): grep folds BOTH the pattern and each line through the shared ``app.rag.retriever._fold`` plus lowercase — an ASCII pattern hits accented content and an accented pattern hits ASCII content, one fold, never drifting from the name-hit path.""" # ASCII pattern, accented content. assert agent.grep_document("The BESTÅ frame (AA-1272068-11-100)", "besta") == [ (1, "The BESTÅ frame (AA-1272068-11-100)") ] assert agent.grep_document("line one\nBÅsta shelf\nline three", "basta") == [ (2, "BÅsta shelf") ] assert agent.grep_document("café-au-lait", "cafe") == [(1, "café-au-lait")] # Accented pattern, ASCII content — the pattern's own accents fold. assert agent.grep_document("The besta frame", "BESTÅ") == [(1, "The besta frame")] # A genuinely non-matching folded pattern still returns no lines. assert agent.grep_document("The besta frame", "bestb") == [] # The fold bridges Ä/Ö/Ü/ß too (the same _fold as name matching). assert agent.grep_document("Straße", "strasse") == [(1, "Straße")] def test_grep_document_case_insensitive_line_numbers() -> None: """Case-insensitive fixed substring, 1-based line numbers, file order, repeated matches within a line collapse to one match (grep semantics).""" content = "The NEEDLE is here\nno hit\nneedle again\nNEEDLE NEEDLE\n" assert agent.grep_document(content, "NEEDLE") == [ (1, "The NEEDLE is here"), (3, "needle again"), (4, "NEEDLE NEEDLE"), ] def test_grep_document_rstrips_lines_and_empty_content() -> None: assert agent.grep_document("hello \t\nworld ", "WORLD") == [(2, "world")] assert agent.grep_document("", "x") == [] assert agent.grep_document("no newlines", "NO") == [(1, "no newlines")] assert agent.grep_document("a\nb\n", "MISSING") == [] def test_grep_whole_kb_grep_style_output(monkeypatch: pytest.MonkeyPatch) -> None: """Whole-KB grep: catalog order, `source/path:line: text` lines, case-insensitive; the call counts in ``tool_calls`` and never touches ``read_docs``; the tools stay offered on the answer request.""" d1 = _doc("Alpha", "a/one.md", "One", "first\nNEEDLE in one\nlast") d2 = _doc("Beta", "b/two.md", "Two", "no hit\nneedle in two\n") monkeypatch.setattr(agent, "all_documents", lambda db: [d1, d2]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "needle"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "Alpha/a/one.md:2: NEEDLE in one\n" "Beta/b/two.md:2: needle in two" ) assert holder.tool_calls == 1 assert holder.read_docs == [] # locked A5: a grep adds no context assert llm.requests[1][1] == _OFFER # tools stay offered def test_grep_capped_at_20_matches_in_catalog_order( monkeypatch: pytest.MonkeyPatch, ) -> None: """The 20-match cap is GLOBAL across documents in catalog order, and the scan stops once it is hit (a 35-match corpus yields exactly 20).""" d1 = _doc("S", "a.md", "A", "\n".join(f"hit-{i}" for i in range(15))) d2 = _doc("S", "b.md", "B", "\n".join(f"hit-{i}" for i in range(20))) monkeypatch.setattr(agent, "all_documents", lambda db: [d1, d2]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "hit-"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) # Phase 160 (task 01): the trailer rides the grep result too — the # 20 raw match lines are the RAW part (the trailer stripped). lines = llm.requests[1][0][3]["content"].removesuffix(agent._budget_note(9)).split("\n") assert len(lines) == agent.SEARCH_MAX_MATCHES assert lines[0] == "S/a.md:1: hit-0" assert lines[14] == "S/a.md:15: hit-14" # all of a.md assert lines[15] == "S/b.md:1: hit-0" # then b.md, in order assert lines[19] == "S/b.md:5: hit-4" # cut at the global cap assert holder.tool_calls == 1 def test_grep_truncates_match_lines_at_200_chars(monkeypatch: pytest.MonkeyPatch) -> None: """A 300-char match line yields exactly 200 chars of it (no crash).""" d1 = _doc("S", "a.md", "A", "top\n" + "x" * 300 + " NEEDLE tail") monkeypatch.setattr(agent, "all_documents", lambda db: [d1]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "needle"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith(f"S/a.md:2: {'x' * agent.SEARCH_LINE_LIMIT}") assert holder.tool_calls == 1 # ---------- grep no-match teaching: the regex-shaped pattern # (the 2026-09-05 "Qwen 3.8" incident — the harness prior is that # grep takes a REGEX; this grep is a fixed substring, owner-locked # A5, and the contract does not change) ---------- def test_plain_form_reduces_regex_to_literal_text() -> None: """The plain-form hint: the pattern reduced to literal text — the incident's exact recovery (``qwen.*3\\.8`` → ``qwen3.8``) plus the edge cases (raw ``.*`` runs dropped before unescape, so an escaped dot survives; first alternative only; classes/quantifiers/parens/ anchors gone; whitespace preserved; pure metacharacters → ``""``). """ assert agent.plain_form(r"qwen.*3\.8") == "qwen3.8" # the incident assert agent.plain_form(r"qwen 3\.8") == "qwen 3.8" assert agent.plain_form(r"Qwen 3\.8") == "Qwen 3.8" # case kept assert agent.plain_form(r"qwen3\.8") == "qwen3.8" assert agent.plain_form(r"llama\.cpp") == "llama.cpp" # escaped dot kept assert agent.plain_form(r"qwen[0-9]+") == "qwen" # class + quantifier assert agent.plain_form("a|b") == "a" # first alternative only assert agent.plain_form(r"\d+") == "" # no literal text — no hint assert agent.plain_form(r".*") == "" # pure wildcard — no hint assert agent.plain_form(r"(qwen)3\.8") == "qwen3.8" # group contents kept assert agent.plain_form("a{2,3}b") == "ab" assert agent.plain_form(r"^qwen$") == "qwen" # anchors dropped assert agent.plain_form(r"a\.b") == "a.b" # escaped dot is a literal assert agent.plain_form("plain") == "plain" # identity for plain text def test_looks_like_regex_detection() -> None: """One metacharacter anywhere marks the pattern regex-shaped; a plain substring (even with a space) does not.""" for p in ( r"qwen.*3\.8", r"qwen 3\.8", "qwen+", "a?b", "x|y", "(a)", "[a-z]", "a^b", "b$c", "a{2}" ): assert agent.looks_like_regex(p) is True, p for p in ("qwen 3.8", "qwen3.8", "plain substring", ""): assert agent.looks_like_regex(p) is False, p def test_grep_no_match_regex_pattern_gets_teaching_line( monkeypatch: pytest.MonkeyPatch, ) -> None: """The incident's shape: a regex-shaped pattern that (necessarily) misses gets the TEACHING no-match line — the plain-substring contract stated, the plain-form retry hint handed over. Still a counted result; the context is untouched (locked A5).""" d1 = _doc("Alpha", "a/one.md", "One", "qwen3.8-27b juggernaut\nno regex text") monkeypatch.setattr(agent, "all_documents", lambda db: [d1]) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="grep", arguments={"pattern": r"qwen.*3\.8"} ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith(agent.NO_MATCHES_REGEX.format( pattern=r"qwen.*3\.8", plain="qwen3.8" )) assert llm.requests[1][0][3]["content"].startswith( "No matches for 'qwen.*3\\.8'. grep matches a plain substring " "(case-insensitive), not a regex — '.*', '\\.' and the like are " "literal text here, so that pattern can never match. Retry with " "the plain text you expect to see (e.g. 'qwen3.8')." ) assert holder.tool_calls == 1 # a no-match with teaching is still a result assert holder.read_docs == [] # locked A5: a grep adds no context def test_grep_no_match_regex_scoped_gets_teaching_line( monkeypatch: pytest.MonkeyPatch, ) -> None: """The scoped teaching variant: the resolved identity is echoed, the hint handed over.""" d1 = _doc("Alpha", "a/one.md", "One", "nothing regex-shaped here") def _find(db: Any, source: str, path: str) -> Document | None: return d1 if (source, path) == ("Alpha", "a/one.md") else None monkeypatch.setattr(agent, "find_document", _find) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="grep", arguments={"pattern": r"qwen 3\.8", "path": "Alpha/a/one.md"}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "No matches for 'qwen 3\\.8' in Alpha/a/one.md. grep matches a " "plain substring (case-insensitive), not a regex — retry with " "the plain text you expect to see (e.g. 'qwen 3.8')." ) assert holder.tool_calls == 1 assert holder.read_docs == [] def test_grep_no_match_plain_pattern_keeps_ordinary_line( monkeypatch: pytest.MonkeyPatch, ) -> None: """A no-match for a PLAIN pattern (no metacharacters — "qwen 3.8" with the space included) keeps the ordinary line byte-identical: the teaching never fires for a well-formed pattern (the retrieval side — the name-hit lexical signal — is what covers that case).""" d1 = _doc("Alpha", "a/one.md", "One", "qwen3.8-27b juggernaut") monkeypatch.setattr(agent, "all_documents", lambda db: [d1]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "qwen 3.8"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "No matches for 'qwen 3.8' in the knowledge base." ) assert holder.tool_calls == 1 def test_grep_matched_regex_pattern_returns_matches_not_teaching( monkeypatch: pytest.MonkeyPatch, ) -> None: """A pattern with metacharacters that MATCHES literally gets the ordinary match output — the teaching can never suppress a real hit (the detection keys on a NO-MATCH only).""" d1 = _doc("S", "a.md", "A", "the C++ compiler is here") monkeypatch.setattr(agent, "all_documents", lambda db: [d1]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "C++"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith("S/a.md:1: the C++ compiler is here") assert holder.tool_calls == 1 def test_grep_no_match_regex_reducing_to_empty_falls_back( monkeypatch: pytest.MonkeyPatch, ) -> None: """A regex-shaped pattern with no literal text left after the reduction (``.*``) gets the ORDINARY line — no empty hint.""" d1 = _doc("Alpha", "a/one.md", "One", "any text at all") monkeypatch.setattr(agent, "all_documents", lambda db: [d1]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="grep", arguments={"pattern": r".*"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "No matches for '.*' in the knowledge base." ) assert holder.tool_calls == 1 def test_no_matches_regex_templates_pin() -> None: """The teaching templates are verbatim pins (the model-facing copy — the mock E2E keys off the plain-substring clause).""" assert agent.NO_MATCHES_REGEX == ( "No matches for '{pattern}'. grep matches a plain substring " "(case-insensitive), not a regex — '.*', '\\.' and the like are " "literal text here, so that pattern can never match. Retry with " "the plain text you expect to see (e.g. '{plain}')." ) assert agent.NO_MATCHES_REGEX_SCOPED == ( "No matches for '{pattern}' in {source}/{path}. grep matches a " "plain substring (case-insensitive), not a regex — retry with " "the plain text you expect to see (e.g. '{plain}')." ) def test_grep_scoped_to_one_document(monkeypatch: pytest.MonkeyPatch) -> None: """Scoped grep: only the named document is loaded (find_document on the first-slash split), ``all_documents`` never runs, and the match line carries its path.""" d1 = _doc("S", "a.md", "A", "needle here") def _find(db: Any, source: str, path: str) -> Document | None: if (source, path) == ("S", "a.md"): return d1 raise AssertionError( f"find_document({source}, {path}) — the scoped " "grep must not load any other document" ) def _boom(*_a: Any, **_k: Any) -> None: raise AssertionError("all_documents must not run for a scoped grep") monkeypatch.setattr(agent, "find_document", _find) monkeypatch.setattr(agent, "all_documents", _boom) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="grep", arguments={"pattern": "needle", "path": "S/a.md"}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith("S/a.md:1: needle here") assert holder.tool_calls == 1 assert holder.read_docs == [] # grepped doc did not enter the context def test_grep_scoped_combined_path_with_nested_path( monkeypatch: pytest.MonkeyPatch, ) -> None: """A combined target whose path itself contains slashes: the split is at the FIRST slash — the scoped grep runs on the right document.""" d1 = _doc("S", "deep/nested/a.md", "A", "needle here") def _find(db: Any, source: str, path: str) -> Document | None: if (source, path) == ("S", "deep/nested/a.md"): return d1 raise AssertionError(f"find_document({source}, {path}) — wrong first-slash split") monkeypatch.setattr(agent, "find_document", _find) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="grep", arguments={"pattern": "needle", "path": "S/deep/nested/a.md"}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith("S/deep/nested/a.md:1: needle here") assert holder.tool_calls == 1 def test_grep_scoped_missing_document_refused(monkeypatch: pytest.MonkeyPatch) -> None: """A scoped ``grep`` miss that matches NO indexed document's ``path`` (zero candidates — the phase-72 lookup runs, finds nothing) keeps today's line byte-identical: a refusal (not counted), tools stay offered.""" monkeypatch.setattr(agent, "find_document", lambda db, source, path: None) monkeypatch.setattr(agent, "all_documents", lambda db: []) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="grep", arguments={"pattern": "x", "path": "S/ghost.md"}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "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] == _OFFER # rejected → tools stay offered def test_grep_scoped_missing_path_like_doc_gets_did_you_mean( monkeypatch: pytest.MonkeyPatch, ) -> None: """The same teaching on the scoped ``grep`` miss: an unresolved path-like scope that matches an indexed document's path gets the ``NO_DOCUMENT_DID_YOU_MEAN`` suggestion line (a refusal — not counted, no context added, tools stay offered); the whole-KB grep is untouched (no ``path`` argument → no scoped resolution at all). """ doc = _doc("Homelab", "active/container_caddy/caddy.md", "Caddy", "CADDY-CONTENT") monkeypatch.setattr(agent, "find_document", lambda db, source, path: None) monkeypatch.setattr(agent, "all_documents", lambda db: [doc]) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="grep", arguments={ "pattern": "needle", "path": "active/container_caddy/caddy.md", }, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( agent.NO_DOCUMENT_DID_YOU_MEAN.format( arg="active/container_caddy/caddy.md", source="Homelab", path="active/container_caddy/caddy.md", ) ) assert holder.tool_calls == 0 and holder.read_docs == [] # a refusal assert llm.requests[1][1] == _OFFER # rejected → tools stay offered def test_grep_scoped_bare_source_name_refused_without_db( monkeypatch: pytest.MonkeyPatch, ) -> None: """A bare source name as the grep target can never resolve to exactly one document — the no-document refusal, no DB lookup, not counted.""" def _boom(*_a: Any, **_k: Any) -> None: raise AssertionError("find_document must not run for a bare source name") monkeypatch.setattr(agent, "find_document", _boom) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="grep", arguments={"pattern": "x", "path": "Homelab"}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "No document at 'Homelab' — check the ls output." ) assert holder.tool_calls == 0 and holder.read_docs == [] @pytest.mark.parametrize( ("arguments", "label"), [ ({}, "no arguments"), ({"pattern": ""}, "empty pattern"), ({"pattern": " "}, "whitespace pattern"), ({"pattern": 42}, "non-string pattern"), ({"pattern": None}, "null pattern"), ], ) def test_grep_missing_arguments_refused( monkeypatch: pytest.MonkeyPatch, arguments: dict[str, Any], label: str ) -> None: """A missing/blank/non-string pattern → the missing-args refusal, with no DB access at all.""" def _boom(*_a: Any, **_k: Any) -> None: raise AssertionError(f"no DB access for a refused grep ({label})") monkeypatch.setattr(agent, "all_documents", _boom) monkeypatch.setattr(agent, "find_document", _boom) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="grep", arguments=arguments)], [StreamPiece("content", "ans")], ) 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] == _OFFER # rejected → tools stay offered def test_grep_no_matches_whole_kb(monkeypatch: pytest.MonkeyPatch) -> None: """Zero hits across the KB → the no-match line (pattern quoted); the grep still executed, so it counts — and never adds context.""" monkeypatch.setattr( agent, "all_documents", lambda db: [_doc("S", "a.md", "A", "nothing here")] ) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "zebra"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( "No matches for 'zebra' in the knowledge base." ) assert holder.tool_calls == 1 assert holder.read_docs == [] def test_grep_no_matches_scoped(monkeypatch: pytest.MonkeyPatch) -> None: """The scoped no-match line is keyed on the resolved source/path.""" doc = _doc("S", "a.md", "A", "nothing here") def _find(db: Any, source: str, path: str) -> Document | None: return doc if (source, path) == ("S", "a.md") else None monkeypatch.setattr(agent, "find_document", _find) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="call_1", name="grep", arguments={"pattern": "zebra", "path": "S/a.md"}, ) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith("No matches for 'zebra' in S/a.md.") assert holder.tool_calls == 1 assert holder.read_docs == [] def test_grep_no_match_truncates_long_pattern(monkeypatch: pytest.MonkeyPatch) -> None: """A pattern longer than 100 chars is truncated in the no-match line (kept short); the grep itself still runs on the full pattern.""" monkeypatch.setattr(agent, "all_documents", lambda db: []) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "p" * 150}) ], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"].startswith( f"No matches for '{'p' * 100}' in the knowledge base." ) assert holder.tool_calls == 1 def test_grep_counts_but_never_adds_context(monkeypatch: pytest.MonkeyPatch) -> None: """The locate-then-read workflow: a grep finds the document but does NOT add it — the subsequent read does (and is not rejected as already-in-context, because the grep touched nothing).""" doc = _doc("S", "a.md", "A", "needle here") monkeypatch.setattr(agent, "all_documents", lambda db: [doc]) monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "needle"})], [ToolCallPiece(id="call_2", name="read", arguments={"path": "S/a.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.tool_calls == 2 # grep + read, both executed assert holder.read_docs == [doc] # only the read added context (A5) assert llm.requests[1][0][3]["content"].startswith("S/a.md:1: needle here") assert llm.requests[2][0][5]["content"].startswith( "Document S/a.md:\ndate: 2024-06-15\nneedle here" ) # ---------- unlimited calls: re-lists and multi-reads (phase 45) ---------- def test_relist_executes_and_counts(monkeypatch: pytest.MonkeyPatch) -> None: """Re-lists execute — a second ``ls`` in one turn returns the top level again and counts in ``tool_calls`` (no budget to exhaust).""" monkeypatch.setattr( agent, "ls_top", lambda db: [("Deployments", 1, None), ("Homelab", 1, None)] ) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [ToolCallPiece(id="call_2", name="ls", arguments={})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.tool_calls == 2 # both re-lists executed and counted listing = "2 sources:\n\nDeployments — 1 documents\nHomelab — 1 documents" # The answer request carries the listing a second time as a tool # result. Phase 160 (task 01): the trailer rides both — 9 left # 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] == _OFFER # still offered (no budgets) def test_multi_read_executes_without_budgets(monkeypatch: pytest.MonkeyPatch) -> None: """Reads are no longer budgeted either — two different documents can be read in one turn (re-reading the same one is still deduped via ALREADY_IN_CONTEXT — see the rejection tests).""" a = _doc("S", "a.md", "A", "A-CONTENT") b = _doc("S", "b.md", "B", "B-CONTENT") monkeypatch.setattr( agent, "find_document", lambda db, source, path: {"a.md": a, "b.md": b}[path] ) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="read", arguments={"path": "S/a.md"})], [ToolCallPiece(id="call_2", name="read", arguments={"path": "S/b.md"})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.read_docs == [a, b] # both reads appended, in order assert holder.tool_calls == 2 assert llm.requests[1][0][3]["content"].startswith( "Document S/a.md:\ndate: 2024-06-15\nA-CONTENT" ) assert llm.requests[2][0][5]["content"].startswith( "Document S/b.md:\ndate: 2024-06-15\nB-CONTENT" ) assert llm.requests[2][1] == _OFFER # the second read was still offered # ---------- round cap (phase 45: replaces the per-tool budgets) ---------- def test_always_ls_bounded_by_round_cap(monkeypatch: pytest.MonkeyPatch) -> None: """A model that keeps calling ``ls`` gets exactly ``agent_max_rounds`` tool rounds, then one forced ``tools=None`` request streams the answer — the cap is the only forced exit.""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) listing = "1 sources:\n\nS — 1 documents" holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [ToolCallPiece(id="call_2", name="ls", arguments={})], [ToolCallPiece(id="call_3", name="ls", arguments={})], [StreamPiece("content", "forced answer")], ) pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=3))) assert [type(p) for p in pieces] == [ ToolCallPiece, ToolCallPiece, ToolCallPiece, StreamPiece, ] assert len(llm.requests) == 4 # 3 tool rounds + the forced answer # The three tool rounds were offered the 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. assert holder.tool_calls == 3 # The final request carries all three executed listings as history. final_msgs = llm.requests[3][0] assert len(final_msgs) == 8 # 2 + 3 rounds × (assistant + tool) # Phase 160 (task 01): the trailer tells the model how many rounds # it has LEFT — 2, then 1, then the spent line (the model is # coached to answer or tell the user to say "continue"). assert final_msgs[3]["content"] == listing + agent.TOOL_BUDGET_REMAINING.format(n=2) assert final_msgs[5]["content"] == listing + agent.TOOL_BUDGET_LAST assert final_msgs[7]["content"] == listing + agent.TOOL_BUDGET_SPENT def test_zero_max_rounds_is_one_request_without_tools() -> None: """``agent_max_rounds=0`` — the kill switch: exactly one request, ``tools=None``, no tool lines, no history growth (byte-identical to the pre-phase-37 path).""" holder = AgentHolder() llm = ScriptedLLM([StreamPiece("thinking", "t "), StreamPiece("content", "direct answer")]) pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=0))) assert [type(p) for p in pieces] == [StreamPiece, StreamPiece] assert len(llm.requests) == 1 assert llm.requests[0][1] is None assert llm.requests[0][0] == [ {"role": "system", "content": "SYSTEM_PROMPT"}, {"role": "user", "content": "QUESTION"}, ] # Phase 160 (task 01): no tools offered → no tool result → the # budget-telemetry trailer exists nowhere on this request. assert not any(m["role"] == "tool" for m in llm.requests[0][0]) assert holder.read_docs == [] and holder.tool_calls == 0 def test_rejected_read_spam_runs_to_round_cap(monkeypatch: pytest.MonkeyPatch) -> None: """Every call rejected (unknown document — "No document at …"): rejections no longer end the loop early via budgets — the round cap bounds them and forces the final no-tools answer. Zero candidates (empty catalog) → the pre-phase-72 line, byte-identical.""" monkeypatch.setattr(agent, "find_document", lambda db, source, path: None) monkeypatch.setattr(agent, "all_documents", lambda db: []) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="read", arguments={"path": "S/ghost.md"})], [ToolCallPiece(id="call_2", name="read", arguments={"path": "S/ghost.md"})], [ToolCallPiece(id="call_3", name="read", arguments={"path": "S/ghost.md"})], [StreamPiece("content", "forced answer")], ) 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] == _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." # Phase 160 (task 01): a rejection consumes a round, so its # refusal carries the budget line too — 2, then 1, then spent. assert llm.requests[1][0][3]["content"] == refusal + agent.TOOL_BUDGET_REMAINING.format(n=2) assert llm.requests[2][0][5]["content"] == refusal + agent.TOOL_BUDGET_LAST assert llm.requests[3][0][7]["content"] == refusal + agent.TOOL_BUDGET_SPENT # ---------- budget telemetry (phase 160, task 01) ---------- def test_budget_note_boundary_table() -> None: """The pure helper: ``n ≥ 2`` → the formatted remaining line, ``1`` → the last-call line, ``≤ 0`` → the spent line.""" assert agent._budget_note(2) == agent.TOOL_BUDGET_REMAINING.format(n=2) assert agent._budget_note(3) == agent.TOOL_BUDGET_REMAINING.format(n=3) assert agent._budget_note(1) == agent.TOOL_BUDGET_LAST assert agent._budget_note(0) == agent.TOOL_BUDGET_SPENT assert agent._budget_note(-1) == agent.TOOL_BUDGET_SPENT def test_budget_trailer_decreases_across_rounds(monkeypatch: pytest.MonkeyPatch) -> None: """The 3-round always-caller: every tool result the model receives ends with its remaining count — 2, then the 1-call line, then the spent line (the boundary call still runs; its result says 0).""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [ToolCallPiece(id="call_2", name="ls", arguments={})], [ToolCallPiece(id="call_3", name="ls", arguments={})], [StreamPiece("content", "forced answer")], ) asyncio.run(_run(llm, holder, _settings(agent_max_rounds=3))) final_msgs = llm.requests[3][0] assert final_msgs[3]["content"].endswith(agent.TOOL_BUDGET_REMAINING.format(n=2)) assert final_msgs[5]["content"].endswith(agent.TOOL_BUDGET_LAST) assert final_msgs[7]["content"].endswith(agent.TOOL_BUDGET_SPENT) def test_single_round_budget_carries_the_spent_line( monkeypatch: pytest.MonkeyPatch, ) -> None: """``agent_max_rounds=1``: the one tool result the model receives says the budget is already spent — the spent line names the user action (say "continue" to keep researching).""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) listing = "1 sources:\n\nS — 1 documents" holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [StreamPiece("content", "forced answer")], ) asyncio.run(_run(llm, holder, _settings(agent_max_rounds=1))) assert len(llm.requests) == 2 assert llm.requests[1][1] is None # the forced final: no tools assert llm.requests[1][0][3]["content"] == listing + agent.TOOL_BUDGET_SPENT def test_rejected_call_carries_the_budget_note() -> None: """An unknown tool (a rejection) still consumes a round — its refusal text ends with the note too, so the budget line is exactly as true there.""" holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="fly_to_the_moon", arguments={})], [StreamPiece("content", "the answer")], ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"] == ( agent.UNKNOWN_TOOL + agent.TOOL_BUDGET_REMAINING.format(n=9) ) # ---------- cap path: reject the model's late calls (phase 160, task 02) ---------- def test_cap_path_tool_calls_rejected_and_budget_fallback( monkeypatch: pytest.MonkeyPatch, ) -> None: """The forced final (``tools=None``) the model answers with structured ``tool_calls`` (the documented ``lite`` behavior — a ``tools=None`` request can still come back with calls): the calls are REJECTED — never executed, never yielded (no ``tool`` frame, no visible markup), counted in ``holder.rejected_calls`` (``tool_calls`` stays executed-only) — and the turn settles with the deterministic ``ROUND_CAP_FALLBACK_ANSWER`` (no exception, no empty answer).""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], # round 1: executed [ # the forced final: structured calls, no content ToolCallPiece(id="call_f1", name="ls", arguments={}), ToolCallPiece(id="call_f2", name="ls", arguments={}), ], ) # No exception — the budget fallback settles the turn. pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=1))) # Only the executed round 1 call streams as a tool piece; the forced # final's two calls are rejected (not yielded at all). assert [type(p) for p in pieces] == [ToolCallPiece, StreamPiece] assert pieces[0] == ToolCallPiece(id="call_1", name="ls", arguments={}) # The fallback is the terminal visible content. assert pieces[1] == StreamPiece("content", agent.ROUND_CAP_FALLBACK_ANSWER) # The attempts are counted; the executed call is unchanged. assert holder.rejected_calls == 2 assert holder.tool_calls == 1 # The forced final request carried no tools (the ``tools=None`` pin). assert len(llm.requests) == 2 assert llm.requests[1][1] is None def test_cap_path_calls_rejected_but_content_streams( monkeypatch: pytest.MonkeyPatch, ) -> None: """A forced final that carries BOTH a structured call and real visible content: the content streams (it is the answer), the call is still rejected (counted, not yielded), and NO fallback is appended (there was visible content).""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], # round 1: executed [ ToolCallPiece(id="call_f1", name="ls", arguments={}), StreamPiece("content", "the real answer"), ], ) pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=1))) # The executed call + the content stream; the rejected call is not # yielded; no fallback (there was visible content). assert [type(p) for p in pieces] == [ToolCallPiece, StreamPiece] assert pieces[1] == StreamPiece("content", "the real answer") assert agent.ROUND_CAP_FALLBACK_ANSWER not in [getattr(p, "text", "") for p in pieces] assert holder.rejected_calls == 1 assert holder.tool_calls == 1 def test_forced_final_carries_the_wrapup_coaching( monkeypatch: pytest.MonkeyPatch, ) -> None: """The forced final's single system message is ``system_prompt + "\n" + ROUND_CAP_WRAPUP_INSTRUCTION`` (the phase-71 :data:`CORRECTION_INSTRUCTION` pattern); the rest of the history (user + the executed round's assistant/tool pair) is byte-identical to what the rounds accumulated — only the system message changes. The round 1 request's system message stays the plain prompt (the coaching is the cap path only).""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) listing = "1 sources:\n\nS — 1 documents" holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [StreamPiece("content", "forced answer")], ) asyncio.run(_run(llm, holder, _settings(agent_max_rounds=1))) # The round 1 request: the plain system prompt, tools offered. assert llm.requests[0][0][0] == {"role": "system", "content": "SYSTEM_PROMPT"} # The forced final: the coached system message, no tools, and the # tail (user + the round 1 assistant/tool pair) unchanged. capped = llm.requests[1][0] assert llm.requests[1][1] is None assert capped[0] == { "role": "system", "content": "SYSTEM_PROMPT\n" + agent.ROUND_CAP_WRAPUP_INSTRUCTION, } assert len(capped) == 4 assert capped[1] == {"role": "user", "content": "QUESTION"} assert capped[2]["role"] == "assistant" assert capped[2]["tool_calls"][0]["function"]["name"] == "ls" assert capped[3]["role"] == "tool" assert capped[3]["content"] == listing + agent.TOOL_BUDGET_SPENT def test_cap_path_empty_forced_final_yields_the_budget_fallback( monkeypatch: pytest.MonkeyPatch, ) -> None: """A forced final that streams NOTHING (no content, no calls, no scaffolding) still settles with the ``ROUND_CAP_FALLBACK_ANSWER`` — the ``final_content == 0`` branch, the pre-phase silent-empty-answer path now gets the honest budget line.""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [], # the forced final: literally nothing ) pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=1))) assert [type(p) for p in pieces] == [ToolCallPiece, StreamPiece] assert pieces[1] == StreamPiece("content", agent.ROUND_CAP_FALLBACK_ANSWER) assert holder.rejected_calls == 0 assert holder.tool_calls == 1 def test_cap_path_logs_the_rejection_and_fallback_warnings( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """Both cap-path warnings appear: the existing ``round cap reached (rounds=N)`` (the phase-45 pin, unchanged) and the new ``tool call(s) attempted after the round cap — rejected, not shown`` (the rejection, named), plus the ``no visible reply`` fallback line. The ``rejected`` warning is named by the count.""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [ToolCallPiece(id="call_f1", name="ls", arguments={})], ) with caplog.at_level(logging.INFO, logger="app.agent"): asyncio.run(_run(llm, holder, _settings(agent_max_rounds=1))) msgs = [r.getMessage() for r in caplog.records] assert any("round cap reached (rounds=1)" in m for m in msgs) assert any( "1 tool call(s) attempted after the round cap — rejected, not shown" in m for m in msgs ) assert any( "round cap hit with no visible reply (rejected_calls=1, scaffold_stripped=0)" in m for m in msgs ) # ---------- rounds / cap_reached holder state (phase 160, task 03) ---------- def test_holder_rounds_counts_rejections_as_rounds() -> None: """``holder.rounds`` counts every call the model emitted on a tools-offered round — an unknown-tool rejection bumps ``rounds`` but NOT ``tool_calls`` (the executed-only counter); a turn that answered inside the cap keeps ``cap_reached`` ``False``.""" holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="fly_to_the_moon", arguments={})], [ToolCallPiece(id="call_2", name="fly_to_the_moon", arguments={})], [StreamPiece("content", "the answer")], ) asyncio.run(_run(llm, holder, _settings())) assert holder.rounds == 2 # both rounds consumed (both rejected) assert holder.tool_calls == 0 # nothing executed assert holder.cap_reached is False # answered inside the cap def test_holder_rounds_and_cap_flags_on_an_in_cap_executed_turn( monkeypatch: pytest.MonkeyPatch, ) -> None: """An in-cap turn with executed rounds: ``holder.rounds`` mirrors the loop's local counter (2 executed rounds), ``tool_calls`` agrees, and ``cap_reached`` stays ``False``.""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [ToolCallPiece(id="call_2", name="ls", arguments={})], [StreamPiece("content", "inside the cap")], ) asyncio.run(_run(llm, holder, _settings(agent_max_rounds=10))) assert holder.rounds == 2 assert holder.tool_calls == 2 assert holder.cap_reached is False def test_holder_reports_the_full_round_count_at_the_cap( monkeypatch: pytest.MonkeyPatch, ) -> None: """A turn that ends at the cap: ``holder.cap_reached`` is ``True`` and ``holder.rounds`` reports the FULL count (the mirror before the forced final); the cap-path's rejected call is NOT a round — it counts in ``rejected_calls``, not ``rounds``.""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], # executed [ # the forced final: one late call, no content ToolCallPiece(id="call_f1", name="ls", arguments={}) ], ) asyncio.run(_run(llm, holder, _settings(agent_max_rounds=1))) assert holder.cap_reached is True assert holder.rounds == 1 # the executed round; the late call is not a round assert holder.rejected_calls == 1 assert holder.tool_calls == 1 def test_holder_defaults_on_the_kill_switch_turn() -> None: """``agent_max_rounds=0``: no rounds are consumed and the cap never runs — the holder reports ``rounds=0`` / ``cap_reached=False`` (the turn shape the per-turn log line renders as ``rounds=0 cap_hit=0``).""" holder = AgentHolder() llm = ScriptedLLM([StreamPiece("content", "direct answer")]) asyncio.run(_run(llm, holder, _settings(agent_max_rounds=0))) assert holder.rounds == 0 assert holder.cap_reached is False # ---------- retries inside the agent loop (phase 67, locked A2) ---------- class FailingLLM: """A scripted fake whose Nth ``chat_stream`` call yields pieces and then raises (phase 67): ``attempts`` is a list of ``(pieces, error)`` — an error after zero pieces = "the endpoint died before the first token"; after some pieces = a mid-stream drop. Records every request's messages/tools and the indices of the attempts whose stream teardown ran (``closed``).""" def __init__( self, attempts: list[tuple[list[StreamPiece | ToolCallPiece], Exception | None]], ) -> None: self.attempts = list(attempts) self.requests: list[tuple[list[dict[str, Any]], list[dict[str, Any]] | None]] = [] #: Indices of attempts whose stream teardown has run. self.closed: list[int] = [] def chat_stream( self, messages: list[dict[str, str]], tools: list[dict[str, Any]] | None = None, scaffolding: ScaffoldingFilter | None = None, # phase 71: fed like the real client ) -> AsyncIterator[StreamPiece | ToolCallPiece]: index = len(self.requests) pieces, error = ( self.attempts[index] if index < len(self.attempts) else ([], LLMError("script exhausted")) ) self.requests.append( (deepcopy(messages), deepcopy(tools) if tools is not None else None) ) return self._attempt(index, pieces, error, scaffolding) async def _attempt( self, index: int, pieces: list[StreamPiece | ToolCallPiece], error: Exception | None, scaffolding: ScaffoldingFilter | None, ) -> AsyncIterator[StreamPiece | ToolCallPiece]: try: for piece in pieces: if ( scaffolding is not None and isinstance(piece, StreamPiece) and piece.kind == "content" ): cleaned = scaffolding.feed(piece.text) if cleaned: yield StreamPiece("content", cleaned) else: yield piece if error is not None: # The tail is NOT flushed on a failed attempt — the real # client only flushes a cleanly completed stream. raise error if scaffolding is not None: tail = scaffolding.flush() if tail: yield StreamPiece("content", tail) finally: self.closed.append(index) def _record_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]: """Monkeypatch ``asyncio.sleep`` (what ``chat_stream_retried`` awaits for the flat pre-retry delay) and record every awaited delay.""" sleeps: list[float] = [] async def fake_sleep(seconds: float) -> None: sleeps.append(seconds) monkeypatch.setattr(asyncio, "sleep", fake_sleep) return sleeps def test_round_retried_before_first_piece( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """A tool round that dies before its first piece is restarted with the same messages: the stream carries a RetryPiece BEFORE the tool call, the tool executes, the final answer streams, and the per-call log line is still emitted exactly once (retries are invisible to the loop).""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = FailingLLM( [ ([], LLMError("connection refused")), ([ToolCallPiece(id="call_1", name="ls", arguments={})], None), ([StreamPiece("content", "Done!")], None), ] ) sleeps = _record_sleeps(monkeypatch) with caplog.at_level(logging.INFO, logger="app.agent"): pieces = asyncio.run( _run(llm, holder, _settings(agent_max_rounds=2, llm_retry_delay=2.5)) ) assert pieces == [ RetryPiece(2, 4), # default llm_retries=3 → 4 attempts ToolCallPiece(id="call_1", name="ls", arguments={}), StreamPiece("content", "Done!"), ] assert holder.tool_calls == 1 assert holder.read_docs == [] # 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] == _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=")] assert len(tool_logs) == 1 # the retry did not re-run the tool or log assert tool_logs[0].getMessage() == "agent tool=ls args={} round=1/2" def test_round_failure_after_first_piece_is_terminal(monkeypatch: pytest.MonkeyPatch) -> None: """Locked A2: a round that already streamed a piece fails the turn — the LLMError propagates out of ``run_agent``, no RetryPiece, no sleep, no second request, and the holder is untouched (the tool never ran).""" holder = AgentHolder() llm = FailingLLM( [([StreamPiece("content", "partial ")], LLMError("mid-stream drop"))] ) sleeps = _record_sleeps(monkeypatch) async def drain() -> list[AgentPiece]: out: list[AgentPiece] = [] with pytest.raises(LLMError, match="mid-stream drop"): async for piece in run_agent( cast("LLMClient", llm), lambda: _mock_session(), system_prompt="SYSTEM_PROMPT", user_message="QUESTION", seed_docs=[], settings=_settings(), holder=holder, ): out.append(piece) return out out = asyncio.run(drain()) assert out == [StreamPiece("content", "partial ")] # no RetryPiece assert len(llm.requests) == 1 # no retry assert sleeps == [] assert holder.read_docs == [] and holder.tool_calls == 0 def test_forced_final_no_tools_call_is_retried(monkeypatch: pytest.MonkeyPatch) -> None: """The forced final request (round cap reached) goes through the same retry rule: a failure before its first piece yields a RetryPiece and restarts with ``tools=None``; the answer from the retry streams.""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = FailingLLM( [ ([ToolCallPiece(id="call_1", name="ls", arguments={})], None), ([ToolCallPiece(id="call_2", name="ls", arguments={})], None), ([], LLMError("down at the cap")), ([StreamPiece("content", "forced answer")], None), ] ) pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=2))) assert [type(p) for p in pieces] == [ ToolCallPiece, ToolCallPiece, RetryPiece, StreamPiece, ] assert pieces[2] == RetryPiece(2, 4) assert pieces[3] == StreamPiece("content", "forced answer") assert len(llm.requests) == 4 # 2 tool rounds + the final + its retry # The forced final (and its retry) carry no tools, whatever is left. assert llm.requests[2][1] is None assert llm.requests[3][1] is None # …and the restart is byte-identical. assert llm.requests[2][0] == llm.requests[3][0] assert holder.tool_calls == 2 def test_zero_retries_is_one_plain_attempt(monkeypatch: pytest.MonkeyPatch) -> None: """The kill-switch path (``llm_retries=0``): a dead round raises immediately — one request, no RetryPiece, no sleep (pre-phase-67 behavior).""" holder = AgentHolder() llm = FailingLLM([([], LLMError("connection refused"))]) sleeps = _record_sleeps(monkeypatch) async def drain() -> list[AgentPiece]: out: list[AgentPiece] = [] with pytest.raises(LLMError, match="connection refused"): async for piece in run_agent( cast("LLMClient", llm), lambda: _mock_session(), system_prompt="SYSTEM_PROMPT", user_message="QUESTION", seed_docs=[], settings=_settings(llm_retries=0), holder=holder, ): out.append(piece) return out out = asyncio.run(drain()) assert out == [] # nothing streamed, no RetryPiece assert len(llm.requests) == 1 assert sleeps == [] assert holder.read_docs == [] and holder.tool_calls == 0 def test_abandon_mid_retry_sleep_leaks_nothing(monkeypatch: pytest.MonkeyPatch) -> None: """Consumer abandon while a retried round is parked in the pre-retry sleep (client disconnect): the driving task is cancelled cleanly, the production teardown ``aclose()`` on ``run_agent`` does not raise, the inner attempt's stream was torn down, and the retry never starts.""" entered = asyncio.Event() async def parking_sleep(seconds: float) -> None: entered.set() await asyncio.Event().wait() # park until the abandon arrives monkeypatch.setattr(asyncio, "sleep", parking_sleep) holder = AgentHolder() llm = FailingLLM( [([], LLMError("endpoint down")), ([StreamPiece("content", "never")], None)] ) async def run() -> None: gen = run_agent( cast("LLMClient", llm), lambda: _mock_session(), system_prompt="SYSTEM_PROMPT", user_message="QUESTION", seed_docs=[], settings=_settings(), holder=holder, ) async def consumer() -> list[AgentPiece]: return [p async for p in gen] task = asyncio.ensure_future(consumer()) await entered.wait() # the round's retry is parked in the sleep assert not task.done() task.cancel() # client disconnect: the driving task is cancelled with pytest.raises(asyncio.CancelledError): await task # Production teardown (phase 48 pattern): must not raise. ``run_agent`` # is an async generator despite its AsyncIterator annotation. await cast( "AsyncGenerator[StreamPiece | ToolCallPiece | RetryPiece, None]", gen ).aclose() asyncio.run(run()) assert len(llm.requests) == 1 # the retry never started assert llm.closed == [0] # attempt 1's inner stream was torn down assert holder.read_docs == [] and holder.tool_calls == 0 def test_retries_are_invisible_to_the_round_cap( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """A failing-then-succeeding round consumes ONE round: with a cap of 2, the retried first round and the second tool round fill the cap — the forced final follows the SECOND call, and the log lines read round=1/2 and round=2/2.""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = FailingLLM( [ ([], LLMError("down")), ([ToolCallPiece(id="call_1", name="ls", arguments={})], None), ([ToolCallPiece(id="call_2", name="ls", arguments={})], None), ([StreamPiece("content", "forced answer")], None), ] ) with caplog.at_level(logging.INFO, logger="app.agent"): pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=2))) assert [type(p) for p in pieces] == [ RetryPiece, ToolCallPiece, ToolCallPiece, StreamPiece, ] assert len(llm.requests) == 4 # 2 (round 1 + its retry) + 1 + the forced final assert llm.requests[3][1] is None # the forced final, after round 2 assert holder.tool_calls == 2 msgs = [r.getMessage() for r in caplog.records] assert "agent tool=ls args={} round=1/2" in msgs assert "agent tool=ls args={} round=2/2" in msgs assert any("round cap reached (rounds=2)" in m for m in msgs) # ---------- prompts: section (HIGH only) ---------- # NOTE (phase 70, task 02): these pins cover the phase-70 TOOLS_SECTION # copy — the harness-aligned ls/read/grep names (the old phase-37/68 # names and the phase-37 per-tool budget line are gone; the round cap # is the bound, not re-stated in the prompt, phase 45). The # LOW/deflection path is untouched by this phase. def test_high_prompt_carries_tools_section_after_documents() -> None: prompt = build_high_prompt([_doc("S", "a.md", "A", "A-CONTENT")]) assert TOOLS_SECTION in prompt # Phase 70: the harness-aligned ls/read/grep copy. assert "`ls`" in prompt assert "`grep`" in prompt assert "`read`" in prompt assert "source: X | path: Y | title: Z" in prompt assert "locator, not a context-adder" in prompt assert "combined `source/path`" in prompt assert "Answer as soon as you have what you need" in prompt # The old names and the per-tool budget restatement are gone. for old in ("list_documents", "read_document", "search_documents"): assert old not in prompt assert "more than one extra document" not in prompt # After the mode body: follows . assert prompt.index("") < prompt.index("") assert prompt.rstrip().endswith("") def test_high_prompt_tools_section_with_notes_and_kb() -> None: prompt = build_high_prompt( [_doc("S", "a.md", "A", "A-CONTENT")], notes=["be concise"], kb_overview="- KB" ) assert prompt.index("") < prompt.index("") assert prompt.index("") < prompt.index("") assert prompt.index("") < prompt.index("") def test_low_prompt_is_byte_identical_and_tool_free() -> None: # Phase 71: the LOW prompt carries the owner-permitted plain-text # line after the DEFLECT_MODE sentence (the marker-keying contract # is unchanged; the line must not leak into the HIGH prompt — # pinned in tests/unit/test_prompts.py). expected = ( _base("LOW") + "\nDEFLECT_MODE: retrieval was weak — the titles below are the closest " "your notes come to the question. They are titles only; do not pretend " "they answer it. Use them to propose 2-3 alternative questions.\n" "Reply in plain text only — you have no tools in this mode.\n" + "- T1\n- T2" ) assert build_deflect_prompt(["T1", "T2"]) == expected for prompt in ( build_deflect_prompt(["T1"]), build_deflect_prompt(["T1"], notes=["be concise"]), build_deflect_prompt(["T1"], kb_overview="- KB"), build_deflect_prompt(["T1"], notes=["be concise"], kb_overview="- KB"), ): assert "" not in prompt assert TOOLS_SECTION not in prompt # ---------- phase 71: the scaffolding recovery policy (deterministic only) ---------- #: The raw span from the 2026-09-03 incident (the E2E mock's trigger, #: task 05) — a complete span the filter strips in full. _INCIDENT_SPAN = ( "<|tool_call_start|>[read(path='/homelab/backup-notes.md')]<|tool_call_end|>" ) def test_correction_instruction_is_the_harness_constant() -> None: """Verbatim constant: the E2E mock (task 05) keys on a stable substring of it, so it must not drift.""" assert agent.CORRECTION_INSTRUCTION == ( "Your previous reply contained raw tool-call markup, which is not " "interpreted here. Answer the user's question directly in plain " "text — no tool syntax." ) def test_malformed_reply_error_subclasses_llm_error() -> None: assert issubclass(MalformedReplyError, LLMError) assert not issubclass(LLMError, MalformedReplyError) def test_scaffolding_only_round_gets_exactly_one_recovery( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """A round whose visible content is pure scaffolding → exactly TWO model requests: the normal round, then the ONE recovery — ``tools=None`` with :data:`CORRECTION_INSTRUCTION` folded into the original single system message (the user message stays last). The clean recovery answer ends the turn, the holder is untouched by the recovery, and the strip was captured in the warning log.""" holder = AgentHolder() llm = ScriptedLLM( [StreamPiece("content", _INCIDENT_SPAN)], [StreamPiece("content", "The clean recovery answer.")], ) with caplog.at_level(logging.WARNING, logger="app.agent"): pieces = asyncio.run(_run(llm, holder, _settings())) # Nothing of the round's scaffolding was yielded — only the 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] == _OFFER assert llm.requests[0][0] == [ {"role": "system", "content": "SYSTEM_PROMPT"}, {"role": "user", "content": "QUESTION"}, ] # The recovery: no tools, a SINGLE system message carrying the folded # correction, the user message last. assert llm.requests[1][1] is None assert llm.requests[1][0] == [ { "role": "system", "content": "SYSTEM_PROMPT\n" + agent.CORRECTION_INSTRUCTION, }, {"role": "user", "content": "QUESTION"}, ] # The recovery is a fixed policy, not a conversation: the holder is # untouched by it. assert holder.read_docs == [] and holder.tool_calls == 0 # The turn total feeds the API layer's ``scaffold_stripped=N`` field. assert holder.scaffold_stripped == len(_INCIDENT_SPAN) # One strip warning per stripped span, the span truncated to 200 chars. strip_logs = [ r for r in caplog.records if r.levelno == logging.WARNING and r.getMessage().startswith("agent: stripped") ] assert len(strip_logs) == 1 message = strip_logs[0].getMessage() assert message.startswith( f"agent: stripped {len(_INCIDENT_SPAN)} chars of tool-scaffolding in round 1:" ) assert _INCIDENT_SPAN[:200] in message def test_scaffolding_twice_settles_with_malformed_reply( monkeypatch: pytest.MonkeyPatch, ) -> None: """The recovery answer is scaffolding again (a second empty reply) — terminal: :class:`MalformedReplyError` (an :class:`LLMError` subclass) after exactly two requests — no third request, no recovery of a recovery.""" holder = AgentHolder() llm = ScriptedLLM( [StreamPiece("content", _INCIDENT_SPAN)], [StreamPiece("content", _INCIDENT_SPAN)], ) with pytest.raises(MalformedReplyError) as excinfo: asyncio.run(_run(llm, holder, _settings())) assert isinstance(excinfo.value, LLMError) assert len(llm.requests) == 2 # exactly one recovery per turn assert llm.requests[1][1] is None assert agent.CORRECTION_INSTRUCTION in llm.requests[1][0][0]["content"] assert holder.scaffold_stripped == 2 * len(_INCIDENT_SPAN) def test_scaffolding_with_real_content_needs_no_recovery( monkeypatch: pytest.MonkeyPatch, ) -> None: """A round with real visible content PLUS scaffolding: the clean content stands — one request only, the clean remainder yielded (no raw tokens), no correction in any system prompt.""" holder = AgentHolder() llm = ScriptedLLM( [ StreamPiece("content", "Here it is: "), StreamPiece("content", _INCIDENT_SPAN), StreamPiece("content", " hope that helps."), ], ) pieces = asyncio.run(_run(llm, holder, _settings())) assert [p for p in pieces if isinstance(p, StreamPiece)] == [ StreamPiece("content", "Here it is: "), StreamPiece("content", " hope that helps."), ] assert len(llm.requests) == 1 # no recovery for messages, _tools in llm.requests: assert all(agent.CORRECTION_INSTRUCTION not in m["content"] for m in messages) assert holder.scaffold_stripped == len(_INCIDENT_SPAN) def test_clean_turn_carries_no_correction_and_no_strip( monkeypatch: pytest.MonkeyPatch, ) -> None: """A clean turn: one request, no correction in any system prompt, zero stripped (the log field stays 0 — uniform).""" holder = AgentHolder() llm = ScriptedLLM( [StreamPiece("thinking", "hmm "), StreamPiece("content", "a clean answer")] ) pieces = asyncio.run(_run(llm, holder, _settings())) assert pieces == [ StreamPiece("thinking", "hmm "), StreamPiece("content", "a clean answer"), ] assert len(llm.requests) == 1 for messages, _tools in llm.requests: assert all(agent.CORRECTION_INSTRUCTION not in m["content"] for m in messages) assert holder.scaffold_stripped == 0 def test_empty_round_without_a_strip_keeps_today_behavior( monkeypatch: pytest.MonkeyPatch, ) -> None: """Round content 0 with NOTHING stripped (an empty/thinking-only answer) → return as today — no recovery, no error.""" holder = AgentHolder() llm = ScriptedLLM([StreamPiece("thinking", "nothing to say, honestly")]) pieces = asyncio.run(_run(llm, holder, _settings())) assert pieces == [StreamPiece("thinking", "nothing to say, honestly")] assert len(llm.requests) == 1 assert holder.scaffold_stripped == 0 def test_scaffolding_round_with_tool_calls_needs_no_recovery( monkeypatch: pytest.MonkeyPatch, ) -> None: """A scaffolding-only round that ALSO carried tool calls: the tool ran, and the policy keys on the no-calls exit only — no recovery (the next round is a normal tools-offered round carrying the tool history).""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = ScriptedLLM( [ StreamPiece("content", _INCIDENT_SPAN), ToolCallPiece(id="call_1", name="ls", arguments={}), ], [StreamPiece("content", "the answer after the tool")], ) pieces = asyncio.run(_run(llm, holder, _settings())) # The round's scaffolding was stripped (no raw delta), the tool frame # and the next round's answer flowed on. assert pieces == [ ToolCallPiece(id="call_1", name="ls", arguments={}), StreamPiece("content", "the answer after the tool"), ] assert len(llm.requests) == 2 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"] for m in messages ) assert holder.tool_calls == 1 assert holder.scaffold_stripped == len(_INCIDENT_SPAN) def test_recovery_after_tool_rounds_keeps_the_history( monkeypatch: pytest.MonkeyPatch, ) -> None: """A scaffolding-only answer round after a tool round: the recovery keeps the SINGLE (folded) system message at the front and the tool history intact behind it — no second system message, no duplicated correction.""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [StreamPiece("content", _INCIDENT_SPAN)], [StreamPiece("content", "recovered after a tool round")], ) pieces = asyncio.run(_run(llm, holder, _settings())) assert pieces == [ ToolCallPiece(id="call_1", name="ls", arguments={}), StreamPiece("content", "recovered after a tool round"), ] assert len(llm.requests) == 3 # tool round + stripped round + recovery assert llm.requests[2][1] is None recovered = llm.requests[2][0] assert recovered[0] == { "role": "system", "content": "SYSTEM_PROMPT\n" + agent.CORRECTION_INSTRUCTION, } assert recovered[1] == {"role": "user", "content": "QUESTION"} assert len(recovered) == 4 assert recovered[2]["role"] == "assistant" assert recovered[3]["role"] == "tool" assert recovered[3]["tool_call_id"] == "call_1" # Phase 160 (task 01): the tool history behind the recovery carries # the trailer (round 1 of a 10-round budget reads 9 left). assert recovered[3]["content"] == ( "1 sources:\n\nS — 1 documents" + agent.TOOL_BUDGET_REMAINING.format(n=9) ) assert sum(1 for m in recovered if m["role"] == "system") == 1 assert holder.scaffold_stripped == len(_INCIDENT_SPAN) def test_forced_final_scaffolding_only_yields_the_budget_fallback( monkeypatch: pytest.MonkeyPatch, ) -> None: """The round-cap forced final (``tools=None``) is filtered too: a scaffolding-only forced answer never reaches the user raw, and — phase 160 (task 02, the recorded revision of the phase-71 cap-path terminal, AGENTS.md rule 3, owner-confirmed 2026-10-05) — the turn NO LONGER settles with :class:`MalformedReplyError`; it settles with the deterministic ``ROUND_CAP_FALLBACK_ANSWER`` (an honest "budget spent" line — the no-visible-content path, no exception). This turn used no recovery, so nothing is doubled up; the strip is still captured in the turn total.""" monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [StreamPiece("content", _INCIDENT_SPAN)], ) # No exception — the budget fallback settles the turn. pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=1))) # The executed round 1 call streams; the forced final's scaffolding is # filtered to nothing; the fallback is the terminal visible content. assert [type(p) for p in pieces] == [ToolCallPiece, StreamPiece] assert pieces[1] == StreamPiece("content", agent.ROUND_CAP_FALLBACK_ANSWER) assert len(llm.requests) == 2 assert llm.requests[1][1] is None # the forced final — no recovery after it assert holder.scaffold_stripped == len(_INCIDENT_SPAN) # The scaffold-only forced final had no visible content and no rejected # 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 # --------------------------------------------------------------------------- # Phase 192 (task 01, A1) — the mid-turn ``VerbatimBlockPiece`` # # The block a successful ``show_text`` appended to ``holder.shown_texts`` # used to reach the client only on the ``done`` frame (phase 182 task 05), # so every verbatim panel popped in AFTER the model finished talking about # the bytes. The loop now also yields ONE ``VerbatimBlockPiece`` the moment # the block exists — after the round's ``tool`` / ``tool_result`` pieces and # before the next model round — and the API layer turns it into the additive # ``verbatim_block`` SSE frame (pinned in ``tests/integration/test_chat_api.py`` # and on the client in task 02). Success-only: every refusal path returns # before the ``shown_texts.append``, so it yields NOTHING. # --------------------------------------------------------------------------- def _patch_two_docs( monkeypatch: pytest.MonkeyPatch, *docs: Document ) -> None: """Resolve several fixture rows by ``(source, path)`` (the multi-document variant of :func:`_patch_show_doc` — a truncated ``read`` doc AND a showable doc in one turn).""" def _find(_db: Session, source: str, path: str) -> Document | None: for doc in docs: if (source, path) == (doc.source, doc.path): return doc return None monkeypatch.setattr(agent, "find_document", _find) _DEFAULT_SHOW_DOC = object() # "the resolvable fixture row" sentinel def _show_turn( monkeypatch: pytest.MonkeyPatch, arguments: dict[str, Any], settings: Settings | None = None, doc: Document | None | object = _DEFAULT_SHOW_DOC, ) -> tuple[AgentHolder, ScriptedLLM, list[AgentPiece]]: """One scripted turn whose model calls ``show_text`` with *arguments* (the default resolves the fixture row; pass ``None`` for the no-document refusal, or an ``is_image`` row for the A6 refusal).""" _patch_show_doc( monkeypatch, _show_doc() if doc is _DEFAULT_SHOW_DOC else cast("Document | None", doc) ) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="show_text", arguments=arguments)], [StreamPiece("content", "ans")], ) return holder, llm, asyncio.run(_run(llm, holder, settings or _settings())) def test_verbatim_block_piece_is_one_frozen_block() -> None: """The piece's shape (the :class:`app.rag.llm.ToolResultPiece` pattern): a FROZEN dataclass with exactly one field, ``block``, holding the server's :class:`app.rag.verbatim.VerbatimBlock` — an immutable wire value, never a re-derivation.""" block = VerbatimBlock( source=_SHOW_SRC, path=_SHOW_PATH, title="Gufo quadlet", start_line=2, end_line=3, total_lines=5, text="L2-two\nL3-three", ) piece = VerbatimBlockPiece(block=block) assert [f.name for f in dataclasses.fields(piece)] == ["block"] assert piece.block is block with pytest.raises(dataclasses.FrozenInstanceError): piece.block = None # pyright: ignore[reportAttributeAccessIssue] def test_show_text_success_yields_one_verbatim_block_piece( monkeypatch: pytest.MonkeyPatch, ) -> None: """The success pin: a scripted ``show_text`` yields EXACTLY ONE ``VerbatimBlockPiece``, AFTER the round's ``ToolCallPiece`` (the ``tool`` frame) and BEFORE the next model round's first piece (the answer's ``StreamPiece``). Its ``block`` IS the ``holder.shown_texts`` entry — the same object, so the mid-turn frame and the done frame can never disagree.""" _patch_show_doc(monkeypatch, _show_doc()) holder = AgentHolder() llm = ScriptedLLM( [ ToolCallPiece( id="c1", name="show_text", arguments={"path": _SHOW_IDENTITY, "offset": 2} ) ], [StreamPiece("content", "here"), StreamPiece("content", " it is")], ) out = asyncio.run(_run(llm, holder, _settings())) pieces = [p for p in out if isinstance(p, VerbatimBlockPiece)] assert len(pieces) == 1 piece = pieces[0] # Order: the tool call, the block, THEN the next round's answer pieces. assert isinstance(out[0], ToolCallPiece) and out[0].name == "show_text" assert out[1] is piece assert all(isinstance(p, StreamPiece) for p in out[2:]) # The block is the holder's own entry (identity, not a copy). assert piece.block is holder.shown_texts[0] assert piece.block.locator == f"{_SHOW_IDENTITY}#2-5" assert piece.block.text == "L2-two\nL3-three\nL4-four\nL5-five" # The done-frame bookkeeping is untouched by the piece (task 05's path). assert holder.tool_calls == 1 assert len(holder.shown_texts) == 1 assert len(llm.requests) == 2 # the tool round + the answer round def test_verbatim_block_piece_lands_after_a_truncation_piece( monkeypatch: pytest.MonkeyPatch, ) -> None: """The emission-order contract with BOTH derived pieces in one turn: a truncated ``read`` (round 1 → ``ToolResultPiece``) then a ``show_text`` (round 2 → ``VerbatimBlockPiece``) stream ``tool, tool_result, tool, verbatim_block`` and only THEN the answer — every derived piece stays inside its own round.""" big = _doc("S", "big.md", "Big", "\n".join(f"line {i}" for i in range(1, 30))) _patch_two_docs(monkeypatch, big, _show_doc()) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="c1", name="read", arguments={"path": "S/big.md"})], [ToolCallPiece(id="c2", name="show_text", arguments={"path": _SHOW_IDENTITY})], [StreamPiece("content", "ans")], ) out = asyncio.run(_run(llm, holder, _settings(read_max_chars=20))) kinds = [ ( "tool:" + p.name if isinstance(p, ToolCallPiece) else "trunc" if isinstance(p, ToolResultPiece) else "block" if isinstance(p, VerbatimBlockPiece) else "retry" if isinstance(p, RetryPiece) else p.kind ) for p in out ] assert kinds == [ "tool:read", "trunc", "tool:show_text", "block", "content", ] assert len([p for p in out if isinstance(p, VerbatimBlockPiece)]) == 1 @pytest.mark.parametrize( "case", ["missing_args", "flag_off", "no_document", "image_doc", "block_cap"], ) def test_show_text_refusals_yield_no_verbatim_block_piece( monkeypatch: pytest.MonkeyPatch, case: str ) -> None: """Every refusal path yields ZERO pieces — the single success gate is the ``shown_texts.append`` (the mid-turn frame is success-only by construction, and the refusal itself is byte-identical to phase 182): the missing-``path`` refusal, the ``answer_text=False`` UNKNOWN_TOOL posture, the no-document teaching, the ``is_image`` refusal (LOCKED A6) and the spent ``show_text_max_blocks`` budget.""" if case == "missing_args": arguments: dict[str, Any] = {} settings, doc = None, _DEFAULT_SHOW_DOC elif case == "flag_off": arguments = {"path": _SHOW_IDENTITY} settings, doc = _settings(answer_text=False), _DEFAULT_SHOW_DOC elif case == "no_document": arguments = {"path": "nosuch/file.md"} settings, doc = None, None elif case == "image_doc": arguments = {"path": _SHOW_IDENTITY} settings, doc = None, _show_doc(is_image=True) else: arguments = {"path": _SHOW_IDENTITY} settings, doc = _settings(show_text_max_blocks=0), _DEFAULT_SHOW_DOC holder, _llm, out = _show_turn(monkeypatch, arguments, settings, doc) assert [p for p in out if isinstance(p, VerbatimBlockPiece)] == [] _assert_refusal(holder) # counts in nothing, displays nothing def test_show_text_already_shown_dedupe_yields_no_second_piece( monkeypatch: pytest.MonkeyPatch, ) -> None: """The canonical-locator dedupe is a REFUSAL (D7), so the second, identical show adds no holder entry and yields no second piece — exactly ONE ``VerbatimBlockPiece`` across both rounds (the client can therefore never be told to paint the same block twice).""" _patch_show_doc(monkeypatch, _show_doc()) holder = AgentHolder() call = ToolCallPiece( id="c1", name="show_text", arguments={"path": _SHOW_IDENTITY, "offset": 2, "limit": 2} ) llm = ScriptedLLM( [call], [ToolCallPiece(id="c2", name="show_text", arguments=call.arguments)], [StreamPiece("content", "ans")], ) out = asyncio.run(_run(llm, holder, _settings())) pieces = [p for p in out if isinstance(p, VerbatimBlockPiece)] assert len(pieces) == 1 assert pieces[0].block.locator == f"{_SHOW_IDENTITY}#2-3" assert len(holder.shown_texts) == 1 # the dedupe refused the second show def test_kill_switch_turn_yields_no_verbatim_block_piece( monkeypatch: pytest.MonkeyPatch, ) -> None: """``BOR_ANSWER_TEXT=0`` (the phase-182 D8 posture): the tool is NOT offered — so no call can succeed — and a scripted call anyway yields zero pieces. The turn's piece sequence is byte-identical to pre-phase (the wire pin lives in ``tests/integration/test_chat_api.py``).""" holder, llm, out = _show_turn( monkeypatch, {"path": _SHOW_IDENTITY}, _settings(answer_text=False) ) offered = [t["function"]["name"] for t in (llm.requests[0][1] or [])] assert "show_text" not in offered assert [p for p in out if isinstance(p, VerbatimBlockPiece)] == [] _assert_refusal(holder)