Files
brain-of-reese/tests/unit/test_image_chips_and_answers.py
T
ducoterra 8172956c15
Build and Push Containers / build-and-push-app (push) Successful in 2m27s
Build and Push Containers / build-and-push-db (push) Failing after 2m8s
phase: 160_tool_budget_telemetry
All green. Final report:

**Phase 160 completion pass — verified (final verification; all 5 tasks already in `complete/`)**

- **Verified, no code defects found:** budget trailers (`TOOL_BUDGET_REMAINING/LAST/SPENT`), cap-path rejection + `ROUND_CAP_FALLBACK_ANSWER` + `ROUND_CAP_WRAPUP_INSTRUCTION`, `done.budget_exhausted` (ABSENT-when-false), `rounds=/cap_hit=` log fields, `agent_max_rounds=10` default, frontend Continue button + restore + shared-page exclusion, mock markers.
- **Tests/lint:** `uv run pytest` 153/153 green (EXIT 0); `uv run pytest --cov=app --cov-report=term-missing` TOTAL **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` 0 errors/0 warnings.
- **Criteria:** ① decreasing trailer pinned by unit tests (9→…→spent) ✅ ② cap path ends readable, no tool lines after cap (E2E) ✅ ③ `budget_exhausted` true-only-on-cap, absent otherwise (integration) ✅ ④ Continue button → one fresh `continue` turn, fresh budget, record intact (E2E) ✅ ⑤ flag survives refresh; shared chat never renders it (`shared.js` has no `budget_exhausted` path) ✅ ⑥ `max_rounds=0` byte-identical single `tools=None` request; default still 10 ✅ ⑦ full suite/coverage/lint green ✅ ⑧ `test_tool_budget_telemetry.py` **7/7** in isolation; all 6 regression E2E suites green in isolation ✅ ⑨ commit + phase move = harness (I make no git history/index changes).
- **Docs:** README.md — fixed: the "Continue researching" button and cap behavior were undocumented (added a Chat-section paragraph + an Agent-tools-section paragraph, incl. the phase-71 recorded-revision note); env-var table, commands, and architecture all verified consistent otherwise. AGENTS.md — verified consistent, no changes needed.
- **Decisions:** no code changes required — phase was fully implemented; only README docs added. No live-infrastructure touched.
- **Next pending phase:** `161_reasoning_bubble`.
2026-10-06 01:42:44 -04:00

1054 lines
44 KiB
Python

"""Unit tests: phase 124 — image chips and answers.
Task 01 covers the ``BOR_ANSWER_IMAGES`` feature toggle
(``Settings.answer_images``). Later tasks in this phase extend this
file (the ``show_image`` tool, the prompt clause, the ``answer_image``
frame key, the auto-cite merge).
"""
from __future__ import annotations
from typing import Any
import pytest
from app.config import Settings
def _settings(**kwargs: Any) -> Settings:
"""Build Settings without reading a .env file (deterministic tests).
Mirrors ``tests/unit/test_config.py::_settings``.
"""
kwargs.setdefault("_env_file", None)
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
def test_answer_images_defaults_off(monkeypatch: pytest.MonkeyPatch) -> None:
"""LOCKED A2: ``BOR_ANSWER_IMAGES`` gates the whole answer-image
feature and is OFF by default — unset, ``=0``, and ``=false`` all
parse to ``False`` (off = byte-identical behavior everywhere)."""
monkeypatch.delenv("BOR_ANSWER_IMAGES", raising=False)
assert _settings().answer_images is False
monkeypatch.setenv("BOR_ANSWER_IMAGES", "0")
assert _settings().answer_images is False
monkeypatch.setenv("BOR_ANSWER_IMAGES", "false")
assert _settings().answer_images is False
def test_answer_images_truthy_env_parse(monkeypatch: pytest.MonkeyPatch) -> None:
"""The settings loader's accepted truthy forms (the
``stream_thinking``/``images`` bool-test precedent): ``1`` and
``true`` enable the toggle."""
monkeypatch.setenv("BOR_ANSWER_IMAGES", "1")
assert _settings().answer_images is True
monkeypatch.setenv("BOR_ANSWER_IMAGES", "true")
assert _settings().answer_images is True
def test_answer_images_independent_of_images(monkeypatch: pytest.MonkeyPatch) -> None:
"""LOCKED A2: the answer-side toggle is a SEPARATE knob from
``images``/``BOR_IMAGES`` — indexing image docs and showing them in
answers are independent owner decisions. Setting one never moves
the other, in either direction."""
monkeypatch.delenv("BOR_ANSWER_IMAGES", raising=False)
monkeypatch.delenv("BOR_IMAGES", raising=False)
# Default: both off.
s = _settings()
assert s.answer_images is False
assert s.images is False
# Enabling the answer toggle alone leaves indexing off — a
# coherent (if image-less) state: the tool is offered, finds no
# image docs, and always refuses.
monkeypatch.setenv("BOR_ANSWER_IMAGES", "1")
s = _settings()
assert s.answer_images is True
assert s.images is False
# Enabling indexing alone leaves the answer side off (byte-
# identical pre-phase-124 behavior).
monkeypatch.delenv("BOR_ANSWER_IMAGES")
monkeypatch.setenv("BOR_IMAGES", "1")
s = _settings()
assert s.answer_images is False
assert s.images is True
# Both on — the full feature state.
monkeypatch.setenv("BOR_ANSWER_IMAGES", "true")
s = _settings()
assert s.answer_images is True
assert s.images is True
# ---------------------------------------------------------------------------
# Task 02 — the ``show_image`` agent tool (toggle-gated)
import asyncio # noqa: E402
import uuid # noqa: E402
from collections.abc import AsyncIterator # noqa: E402
from copy import deepcopy # noqa: E402
from datetime import UTC, datetime # noqa: E402
from typing import cast # noqa: E402
from unittest.mock import MagicMock # noqa: E402
from sqlalchemy.orm import Session # noqa: E402
from app.models import Document # noqa: E402
from app.rag.agent import ( # noqa: E402
AGENT_TOOLS,
ALREADY_SHOWING,
IMAGE_DOC_MARKER,
MISSING_SHOW_ARGS,
NOT_AN_IMAGE_DOC,
SHOW_IMAGE_TOOL,
AgentHolder,
_execute_tool,
_no_document_refusal,
agent_tools,
run_agent,
)
from app.rag.llm import LLMClient, StreamPiece, ToolCallPiece # noqa: E402
#: The fixture documents' fixed creation date (phase 106, D5): the
#: ``show_image`` result formats ``doc.created_at`` on its date line —
#: a pinned value keeps the result-string assertions exact.
_SHOW_FIXTURE_CREATED_AT = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
# --- the offer list: toggle-gated, the base three byte-identical ---
def test_agent_tools_off_is_the_three_base_tools() -> None:
"""LOCKED A2: toggle off (the default) → exactly the three base
tools, element-for-element equal to ``AGENT_TOOLS`` — and a FRESH
list (not the constant), so a caller mutating the offer can never
mutate the constant."""
off = agent_tools(_settings())
assert [t["function"]["name"] for t in off] == ["ls", "read", "grep"]
assert off == AGENT_TOOLS # element-for-element
assert off is not AGENT_TOOLS # a fresh list, never the constant itself
off.append({"junk": True}) # mutating the offer must not touch the constant
assert len(AGENT_TOOLS) == 3
def test_agent_tools_on_appends_show_image() -> None:
"""Toggle on → the three base tools (byte-identical) plus the 4th
tool, ``show_image`` — the offer list the grounded turn passes to
the model request (LOCKED A2: ``BOR_ANSWER_IMAGES`` gates ONLY the
offer; the base three are unchanged)."""
on = agent_tools(_settings(answer_images=True))
assert [t["function"]["name"] for t in on] == ["ls", "read", "grep", "show_image"]
assert on[:3] == AGENT_TOOLS # the base three byte-identical
assert on[3] == SHOW_IMAGE_TOOL
def test_show_image_tool_definition_carries_the_relevance_gate() -> None:
"""The tool definition is the same OpenAI function shape as the
other three (one required string arg ``path``, the combined
``source/path`` identity — the bare-path-will-not-resolve teaching
mirrored from ``read``), and its description IS the relevance gate
(LOCKED A5: never a merely related image, at most ONE image, the
full text answer always stands)."""
fn = SHOW_IMAGE_TOOL["function"]
assert SHOW_IMAGE_TOOL["type"] == "function"
assert fn["name"] == "show_image"
assert fn["parameters"]["required"] == ["path"]
assert fn["parameters"]["properties"]["path"]["type"] == "string"
desc = fn["description"]
assert "at most one image" in desc
assert "never for a merely related" in desc
assert "answer fully in text either way" in desc
assert "bare document path will not resolve" in desc
def test_run_agent_gates_the_show_image_offer() -> None:
"""The wiring pin (completion criteria): a grounded turn's offer
(the ``tools`` on the model request, ``max_rounds`` > 0) is the
three base tools when the toggle is off — element-for-element the
pre-phase-124 list — and gains ``show_image`` only when it is on."""
class _ScriptedLLM:
"""One canned content-only round; records the ``tools`` every
request carried (the DB is never touched — no tool calls)."""
def __init__(self) -> None:
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: object | None = None,
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
self.requests.append((deepcopy(messages), tools))
yield StreamPiece("content", "Done!")
def _mock_session() -> Session:
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 _consume(llm: _ScriptedLLM, settings: Settings) -> None:
async for _piece in run_agent(
cast("LLMClient", llm),
_mock_session,
system_prompt="SYSTEM_PROMPT",
user_message="QUESTION",
seed_docs=[],
settings=settings,
holder=AgentHolder(),
):
pass
llm_off = _ScriptedLLM()
asyncio.run(_consume(llm_off, _settings()))
assert len(llm_off.requests) == 1
assert llm_off.requests[0][1] == AGENT_TOOLS # off: element-for-element pre-124
llm_on = _ScriptedLLM()
asyncio.run(_consume(llm_on, _settings(answer_images=True)))
sent = llm_on.requests[0][1]
assert sent is not None # the grounded turn (max_rounds > 0) offers tools
assert [t["function"]["name"] for t in sent] == [
"ls",
"read",
"grep",
"show_image",
]
# --- the handler: the refusal matrix + the confirming success ---
def test_show_image_refusal_texts_are_pinned() -> None:
"""The refusal texts (the ``MISSING_READ_ARGS`` /
``ALREADY_IN_CONTEXT`` shapes) are pinned — the model's
self-correction relies on them being stable."""
assert MISSING_SHOW_ARGS == "show_image requires a string argument 'path'."
assert ALREADY_SHOWING == (
"Already showing an image this answer — do not show another one. "
"Answer from what you have."
)
assert NOT_AN_IMAGE_DOC.format(arg="s/p.png") == (
"'s/p.png' is not an image document — only standalone image "
"documents can be shown as answers."
)
def _show_call(arguments: dict[str, Any]) -> ToolCallPiece:
return ToolCallPiece(id="call_show", name="show_image", arguments=arguments)
def _seed_show_doc(
db: Session, *, is_image: bool, content: str, path: str
) -> Document:
"""One real documents row ``show_image`` can resolve (source
``showsrc`` — no fixture collision); cleaned up by the caller."""
doc = Document(
id=uuid.uuid4(),
source="showsrc",
path=path,
full_path=f"/showsrc/{path}",
title="diagram" if is_image else "Notes",
content=content,
content_hash="0" * 64,
created_at=_SHOW_FIXTURE_CREATED_AT,
is_image=is_image,
)
if is_image:
doc.image_path = f"/tmp/{doc.id}.png"
db.add(doc)
db.commit()
return doc
def _show(db: Session, settings: Settings, holder: AgentHolder, arguments: dict[str, Any]) -> str:
return _execute_tool(db, _show_call(arguments), [], holder, settings)
def test_show_image_missing_or_empty_path_is_refused(db: Session) -> None:
"""(a) missing/empty/whitespace/non-string ``path`` →
``MISSING_SHOW_ARGS`` — counts in nothing, nothing shown."""
holder = AgentHolder()
for arguments in ({}, {"path": " "}, {"path": None}):
assert _show(db, _settings(), holder, arguments) == MISSING_SHOW_ARGS
assert holder.tool_calls == 0
assert holder.shown_images == []
def test_show_image_unresolvable_arg_gets_the_no_document_teaching(
db: Session,
) -> None:
"""(b) an unresolvable argument → the phase-72
``_no_document_refusal`` text (the bare-argument line, and the
did-you-mean combined-identity for a path-like argument) — counts
in nothing, nothing shown."""
holder = AgentHolder()
# A bare argument (no ``/``) can never be a document — the
# no-DB-lookup refusal, byte-identical to the read branch's line.
assert _show(db, _settings(), holder, {"path": "nosuchdoc"}) == (
"No document at 'nosuchdoc' — check the ls output."
)
assert holder.tool_calls == 0
assert holder.shown_images == []
# A path-like argument matching exactly ONE indexed document's path
# gets the combined-identity teaching.
doc = _seed_show_doc(db, is_image=True, content="A red square.", path="phase124/diagram.png")
try:
result = _show(db, _settings(), holder, {"path": "phase124/diagram.png"})
assert result == _no_document_refusal(db, "phase124/diagram.png")
assert "showsrc/phase124/diagram.png" in result # the combined identity named
assert holder.tool_calls == 0
assert holder.shown_images == []
finally:
db.delete(doc)
db.commit()
def test_show_image_of_a_text_doc_is_refused_not_converted(db: Session) -> None:
"""(c) a resolved TEXT document → ``NOT_AN_IMAGE_DOC`` (the argument
echoed) — a text doc is refused, NEVER silently converted to a
read; counts in nothing, nothing shown or read."""
doc = _seed_show_doc(
db, is_image=False, content="just text.", path="phase124/notes.md"
)
try:
holder = AgentHolder()
result = _show(db, _settings(), holder, {"path": "showsrc/phase124/notes.md"})
assert result == NOT_AN_IMAGE_DOC.format(arg="showsrc/phase124/notes.md")
assert holder.tool_calls == 0
assert holder.shown_images == []
assert holder.read_docs == [] # never converted to a read
finally:
db.delete(doc)
db.commit()
def test_show_image_of_an_image_doc_confirms_and_records(db: Session) -> None:
"""(d) a resolved IMAGE document → the confirming result — the NEW
first line (absent from ``read``) followed by the exact
read-style document block (the ``Document …`` header + date line +
:data:`IMAGE_DOC_MARKER` + the description), ``shown_images ==
[doc]``, and ``tool_calls`` bumped (an executed call — it feeds the
per-turn ``tool_calls=N``)."""
doc = _seed_show_doc(
db,
is_image=True,
content="A red square on a white background.",
path="phase124/diagram.png",
)
try:
holder = AgentHolder()
result = _show(db, _settings(), holder, {"path": "showsrc/phase124/diagram.png"})
assert result == (
"Image shown to the user — it is now part of your answer.\n"
"Document showsrc/phase124/diagram.png:\n"
"date: 2024-06-15\n"
f"{IMAGE_DOC_MARKER}\n"
"A red square on a white background."
)
assert holder.shown_images == [doc]
assert holder.tool_calls == 1
finally:
db.delete(doc)
db.commit()
def test_show_image_second_call_is_refused(db: Session) -> None:
"""(e) a second ``show_image`` in the same turn →
``ALREADY_SHOWING`` (LOCKED A3, server-enforced) — appends nothing,
counts in nothing; the first document stays the one shown."""
doc = _seed_show_doc(
db, is_image=True, content="A red square.", path="phase124/diagram2.png"
)
try:
holder = AgentHolder()
first = _show(db, _settings(), holder, {"path": "showsrc/phase124/diagram2.png"})
assert first.startswith("Image shown to the user — it is now part of your answer.")
second = _show(db, _settings(), holder, {"path": "showsrc/phase124/diagram2.png"})
assert second == ALREADY_SHOWING
assert holder.shown_images == [doc] # still exactly one shown
assert holder.tool_calls == 1 # the refusal counts in nothing
finally:
db.delete(doc)
db.commit()
# ---------------------------------------------------------------------------
# Task 03 — the ``show_image`` prompt clause (toggle-gated)
#
# The clause body itself is pinned in ``tests/unit/test_prompts.py``
# (``SHOW_IMAGE_CLAUSE`` + the ``build_high_prompt`` builds). This
# section pins the chat-side wiring: the grounded plan carries the
# clause EXACTLY when ``settings.answer_images`` is true, and a
# deflected plan never does.
from app.api.chat import plan_turn # noqa: E402
from app.rag.prompts import SHOW_IMAGE_CLAUSE, build_high_prompt # noqa: E402
from app.rag.retriever import RetrievedChunk # noqa: E402
#: A detached document row + its chunk — ``plan_turn`` is pure (no DB),
#: so the wiring is pinned without a database (the cosine 0.9 clears
#: the 0.62 default ``relevance_threshold`` → HIGH/grounded; the
#: 0.2 / no-fts chunk stays LOW/deflected — the gate is strict).
_P3_DOC = Document(
id=uuid.uuid4(),
source="Homelab",
path="kubernetes.md",
full_path="/tmp/kubernetes.md",
title="Kubernetes Homelab Cluster",
content="Talos Linux on three nodes.",
content_hash="0" * 64,
created_at=_SHOW_FIXTURE_CREATED_AT,
summary="A Talos Linux cluster on three nodes.",
)
def _plan3(cosine: float, settings: Settings):
chunk = RetrievedChunk(
chunk_id=uuid.uuid4(),
position=0,
content="kubernetes",
score=1.0,
document=_P3_DOC,
cosine=cosine,
)
return plan_turn([chunk], settings)
def test_plan_turn_grounded_carries_clause_exactly_when_toggle_on() -> None:
"""Completion pin: the grounded plan's system prompt is BYTE-
IDENTICAL to the pre-task build when ``answer_images`` is off (the
default — the pre-phase pins pass untouched) and gains the clause
(inside ``<tools>``) exactly when it is on."""
off = _plan3(0.9, _settings())
assert off.deflected is False
assert off.system_prompt == build_high_prompt([_P3_DOC])
assert SHOW_IMAGE_CLAUSE not in off.system_prompt
on = _plan3(0.9, _settings(answer_images=True))
assert on.deflected is False
assert on.system_prompt == build_high_prompt([_P3_DOC], show_image=True)
assert SHOW_IMAGE_CLAUSE in on.system_prompt
assert on.system_prompt.index("<tools>") < on.system_prompt.index(SHOW_IMAGE_CLAUSE)
assert on.system_prompt.index(SHOW_IMAGE_CLAUSE) < on.system_prompt.index("</tools>")
def test_plan_turn_deflected_never_carries_the_clause() -> None:
"""A deflected turn has no tools and never shows an image (the
phase's locked design): the deflection prompt takes no such flag,
so its build is identical — and clause-free — whether the toggle
is off or on."""
off = _plan3(0.2, _settings())
assert off.deflected is True
assert SHOW_IMAGE_CLAUSE not in off.system_prompt
assert "<tools>" not in off.system_prompt
on = _plan3(0.2, _settings(answer_images=True))
assert on.deflected is True
assert on.system_prompt == off.system_prompt # toggle has no effect at all
assert SHOW_IMAGE_CLAUSE not in on.system_prompt
# ---------------------------------------------------------------------------
# Task 04 — the ``answer_image`` done frame + saved-chat persistence
#
# The wire contract: ``ChatDoneEvent.answer_image`` (the shown doc's
# :class:`SourceRef`, OMITTED from the frame when absent — every
# toggle-off / image-less turn stays byte-identical) and
# ``ChatMessage.answer_image`` (the brain-record key the done-time
# auto-save persists).
#
from json import dumps as _json_dumps # noqa: E402
from pydantic import ValidationError # noqa: E402
from app.schemas import ChatDoneEvent, ChatMessage, SourceRef # noqa: E402
#: The shown image doc's full ref (all four fields — a shown doc is an
#: image doc, so its ref always carries the ``image_url`` bytes route).
_SHOWN_REF = {
"source": "docs",
"path": "pic.png",
"title": "pic",
"image_url": "/api/documents/0e8a6e5d-7b6c-4f2a-9c1d-3e5f7a9b1c2d/image",
}
def test_done_event_without_answer_image_drops_the_key() -> None:
"""``answer_image=None`` (every deflected / image-less / pre-phase
turn) → ``model_dump()`` has NO ``answer_image`` key — ABSENT, never
``null`` — and the dump is byte-identical to the pre-phase frame
shape (the byte-identical-off contract, task 01). Checked on the
dict AND on the raw JSON (no ``"answer_image"`` token, no
``null`` field)."""
event = ChatDoneEvent(
deflected=False,
sources=[SourceRef(source="docs", path="a.md", title="A")],
related=[SourceRef(source="docs", path="b.md", title="B")],
suggestions=["Maybe try X?"],
)
dump = event.model_dump()
assert "answer_image" not in dump
# The pre-phase-124 key set, exactly.
assert set(dump) == {"type", "deflected", "sources", "related", "suggestions"}
assert _json_dumps(dump, sort_keys=True) == _json_dumps(
{
"type": "done",
"deflected": False,
"sources": [{"source": "docs", "path": "a.md", "title": "A"}],
"related": [{"source": "docs", "path": "b.md", "title": "B"}],
"suggestions": ["Maybe try X?"],
},
sort_keys=True,
)
raw = _json_dumps(dump)
assert "answer_image" not in raw
def test_done_event_without_answer_image_parses_a_pre_phase_frame() -> None:
"""Back-compat: a pre-phase-124 done frame (no ``answer_image``
key) parses with the default ``None`` and re-dumps byte-identically
(PLAN §4 — the field is additive in both directions)."""
old_payload = {
"type": "done",
"deflected": True,
"sources": [],
"related": [],
"suggestions": [],
}
event = ChatDoneEvent(**old_payload)
assert event.answer_image is None
assert event.model_dump() == old_payload
def test_done_event_with_answer_image_carries_the_full_ref() -> None:
"""``answer_image`` set (the shown doc) → the key is present with
ALL FOUR ref fields (source/path/title + the ``image_url`` bytes
route), and the existing ``sources``/``related``/``suggestions``
serialization is untouched."""
event = ChatDoneEvent(
deflected=False,
sources=[SourceRef(source="docs", path="pic.png", title="pic")],
related=[],
suggestions=[],
answer_image=SourceRef(**_SHOWN_REF),
)
dump = event.model_dump()
assert dump["answer_image"] == _SHOWN_REF
assert dump["sources"] == [{"source": "docs", "path": "pic.png", "title": "pic"}]
assert dump["related"] == []
assert dump["suggestions"] == []
assert dump["type"] == "done"
assert dump["deflected"] is False
def test_done_event_answer_image_ref_drops_its_own_null_image_url() -> None:
"""The ref's OWN serializer still applies inside the frame: a
text-shaped ref (``image_url=None`` — a corner that cannot occur
server-side, since only image docs are shown, but a client-built
frame could carry it) keeps the key and drops the null field
(the :class:`SourceRef.image_url` omission rule)."""
event = ChatDoneEvent(
deflected=False,
sources=[],
answer_image=SourceRef(source="s", path="p.png", title="t"),
)
dump = event.model_dump()
assert dump["answer_image"] == {"source": "s", "path": "p.png", "title": "t"}
assert "image_url" not in dump["answer_image"]
# --- the ``budget_exhausted`` done key (phase 160, task 03) ---
def test_done_event_without_budget_exhausted_drops_the_key() -> None:
"""``budget_exhausted=False`` (the default — every deflected /
in-cap / pre-phase turn) → ``model_dump()`` has NO
``budget_exhausted`` key — ABSENT, never ``false`` — and a full
done frame (sources + related + suggestions, no flag) serializes
byte-identically to the pre-phase expectation (the
byte-identical-off contract, PLAN §13)."""
event = ChatDoneEvent(
deflected=False,
sources=[SourceRef(source="docs", path="a.md", title="A")],
related=[SourceRef(source="docs", path="b.md", title="B")],
suggestions=["Maybe try X?"],
)
dump = event.model_dump()
assert "budget_exhausted" not in dump
# The pre-phase-160 key set, exactly (answer_image absent too).
assert set(dump) == {"type", "deflected", "sources", "related", "suggestions"}
assert _json_dumps(dump, sort_keys=True) == _json_dumps(
{
"type": "done",
"deflected": False,
"sources": [{"source": "docs", "path": "a.md", "title": "A"}],
"related": [{"source": "docs", "path": "b.md", "title": "B"}],
"suggestions": ["Maybe try X?"],
},
sort_keys=True,
)
raw = _json_dumps(dump)
assert "budget_exhausted" not in raw
def test_done_event_with_budget_exhausted_carries_the_key() -> None:
"""``budget_exhausted=True`` (a cap-reached turn) → the key is
present and ``true``, the rest of the frame untouched; an
explicit ``False`` behaves exactly like the default (absent)."""
dump = ChatDoneEvent(deflected=False, sources=[], budget_exhausted=True).model_dump()
assert dump["budget_exhausted"] is True
assert dump["type"] == "done"
assert dump["deflected"] is False
assert dump["related"] == []
assert dump["suggestions"] == []
explicit_false = ChatDoneEvent(
deflected=False, sources=[], budget_exhausted=False
).model_dump()
assert "budget_exhausted" not in explicit_false
def test_done_event_budget_exhausted_and_answer_image_are_independent() -> None:
"""Both optional keys shown on the same turn: the two omission
rules are independent — a cap-reached turn with a shown answer
image carries BOTH keys; the answer-image ref's own serializer
still applies inside the frame."""
dump = ChatDoneEvent(
deflected=False,
sources=[],
answer_image=SourceRef(**_SHOWN_REF),
budget_exhausted=True,
).model_dump()
assert dump["budget_exhausted"] is True
assert dump["answer_image"] == _SHOWN_REF
def test_done_event_without_budget_exhausted_parses_a_pre_phase_frame() -> None:
"""Back-compat: a pre-phase-160 done frame (no
``budget_exhausted`` key) parses with the default ``False`` and
re-dumps byte-identically (PLAN §4 — the field is additive in
both directions)."""
old_payload = {
"type": "done",
"deflected": True,
"sources": [],
"related": [],
"suggestions": [],
}
event = ChatDoneEvent(**old_payload)
assert event.budget_exhausted is False
assert event.model_dump() == old_payload
# --- the ``ChatMessage`` boundary (the saved-chat record key) ---
def test_chat_message_brain_record_with_answer_image_parses() -> None:
"""A brain record carrying ``answer_image`` (the shown doc's full
ref shape) parses — the phase-113 regression: without the field,
the ``extra="forbid"`` boundary would 422 every done-time auto-save
that carries the key (the A2 quiet-failure path)."""
msg = ChatMessage.model_validate(
{"who": "brain", "text": "Here's the diagram…", "answer_image": _SHOWN_REF}
)
assert msg.answer_image is not None
assert msg.answer_image.source == "docs"
assert msg.answer_image.path == "pic.png"
assert msg.answer_image.title == "pic"
assert msg.answer_image.image_url == _SHOWN_REF["image_url"]
def test_chat_message_without_or_null_answer_image_parses() -> None:
"""``answer_image`` ABSENT (every pre-phase-124 record) and explicit
``None`` both parse (the default) — and neither serializes the key
(ABSENT, never ``null`` — pre-phase and image-less records
round-trip byte-identically, the phase-50 contract)."""
absent = ChatMessage.model_validate({"who": "brain", "text": "hi"})
assert absent.answer_image is None
assert "answer_image" not in absent.model_dump()
explicit_null = ChatMessage.model_validate(
{"who": "brain", "text": "hi", "answer_image": None}
)
assert explicit_null.answer_image is None
assert "answer_image" not in explicit_null.model_dump()
def test_chat_message_with_answer_image_serializes_the_ref() -> None:
"""A record WITH a shown ref keeps it in the dump — the restore
path re-renders the answer figure from it (the round-trip)."""
msg = ChatMessage.model_validate(
{"who": "brain", "text": "Here's the diagram…", "answer_image": _SHOWN_REF}
)
dump = msg.model_dump()
assert dump["answer_image"] == _SHOWN_REF
def test_chat_message_user_image_field_still_parses_independently() -> None:
"""The user record's phase-123 ``image`` (the stored PATH string of
the question's attachment) is untouched and semantically distinct:
it still parses on a user record, still serializes the path, and is
independent of ``answer_image`` (a brain record carries the shown
ref, never the attachment path)."""
user = ChatMessage.model_validate(
{
"who": "user",
"text": "What is in this image?",
"image": "/api/chat-images/" + "a" * 32 + ".png",
}
)
assert user.image == "/api/chat-images/" + "a" * 32 + ".png"
assert user.answer_image is None
dump = user.model_dump()
assert dump["image"] == user.image
assert "answer_image" not in dump
def test_chat_message_answer_image_without_image_url_parses() -> None:
"""``answer_image`` missing ``image_url`` parses — the field is
optional on :class:`SourceRef` (its own serializer then drops the
null on the way out)."""
msg = ChatMessage.model_validate(
{
"who": "brain",
"text": "hi",
"answer_image": {"source": "s", "path": "p.png", "title": "t"},
}
)
assert msg.answer_image is not None
assert msg.answer_image.image_url is None
assert msg.model_dump()["answer_image"] == {"source": "s", "path": "p.png", "title": "t"}
def test_chat_message_phase83_bounds_still_bind_the_answer_image_ref() -> None:
"""The phase-83 value bounds bind the ref's fields EXACTLY like
every other :class:`SourceRef` (``source`` ≤ 120, ``path`` ≤ 1000,
``title`` ≤ 500 — the ``documents`` column lengths): one over any
cap is a 422 at the boundary."""
for bad in (
{"source": "s" * 121, "path": "p", "title": "t"},
{"source": "s", "path": "p" * 1001, "title": "t"},
{"source": "s", "path": "p", "title": "t" * 501},
):
with pytest.raises(ValidationError):
ChatMessage.model_validate(
{"who": "brain", "text": "hi", "answer_image": bad}
)
# Exactly AT the caps: parses (a server-built ref from a full-length
# row fits by construction — the same pin as the done-event ref).
at_caps = ChatMessage.model_validate(
{
"who": "brain",
"text": "hi",
"answer_image": {"source": "s" * 120, "path": "p" * 1000, "title": "t" * 500},
}
)
assert at_caps.answer_image is not None
# ---------------------------------------------------------------------------
# Task 05 — bug fix: suggested-tier image docs earn their source chips
#
# ``merge_cited_docs`` (``app/api/chat.py``) composes the citation tier
# (the done frame's ``sources``): the phase-119 read-docs-only rule
# plus phase 124's single documented exception — a suggested-tier image
# doc's seed block IS its entire content (content == summary == the
# vision description, phase 122 task 03), so "suggested" ≡ "read" for
# it and it is auto-cited even on a zero-read grounded turn (a ``read``
# would add only the marker line — the bug: the chip never appeared).
from app.api.chat import merge_cited_docs # noqa: E402
def _uc_doc(source: str, path: str, *, is_image: bool = False) -> Document:
"""A detached documents row (no DB) — ``merge_cited_docs`` touches
only the ``(source, path)`` identity and ``is_image``."""
return Document(
id=uuid.uuid4(),
source=source,
path=path,
full_path=f"/{source}/{path}",
title=path.rsplit("/", 1)[-1],
content="A red square." if is_image else "just text.",
content_hash="0" * 64,
created_at=_SHOW_FIXTURE_CREATED_AT,
is_image=is_image,
)
def test_merge_cited_docs_zero_read_suggested_image_doc_is_cited() -> None:
"""(a) The bug fix: a suggested-tier IMAGE doc + zero reads → it
IS cited (the zero-read grounded turn — the phase-119 "chips
nothing" fast path — chips it; pre-phase-124 this returned
``[]``)."""
img = _uc_doc("docs", "pic.png", is_image=True)
assert merge_cited_docs([], [img], deflected=False) == [img]
def test_merge_cited_docs_read_image_doc_appears_once_at_read_position() -> None:
"""(b) Dedup: an image doc the agent ALSO explicitly read appears
exactly ONCE — at its READ position (the read loop sees it first;
the suggestion merge must not re-add or relocate it)."""
img = _uc_doc("docs", "pic.png", is_image=True)
text = _uc_doc("docs", "notes.md")
merged = merge_cited_docs([img, text], [img, text], deflected=False)
assert merged == [img, text] # once each, read order
assert merged.count(img) == 1
# Read position preserved even when the suggestion order differs:
# the image doc read SECOND stays second (the suggested tier does
# not move read docs).
merged = merge_cited_docs([text, img], [img], deflected=False)
assert merged == [text, img]
def test_merge_cited_docs_zero_read_suggested_text_doc_not_cited() -> None:
"""(c) The phase-119 contract intact: a suggested TEXT doc + zero
reads → NOT cited (auto-citation is image docs only — a zero-read
text turn still chips nothing)."""
text = _uc_doc("docs", "notes.md")
assert merge_cited_docs([], [text], deflected=False) == []
def test_merge_cited_docs_deflected_cites_nothing() -> None:
"""(d) A deflected turn cites NOTHING (phase 112) — even when the
suggested tier (the weak hits, A3) holds image docs and even when
``read_docs`` is non-empty by construction (deflected turns run no
agent — the guard makes the contract explicit)."""
img = _uc_doc("docs", "pic.png", is_image=True)
assert merge_cited_docs([], [img], deflected=True) == []
assert merge_cited_docs([img], [img], deflected=True) == []
def test_merge_cited_docs_two_suggested_image_docs_in_suggestion_order() -> None:
"""(e) Two suggested image docs (zero reads, text docs interleaved
in the tier) → BOTH are cited, in SUGGESTION order (the retrieval
rank is the chip order) — the text members are skipped."""
text1 = _uc_doc("docs", "a.md")
second = _uc_doc("docs", "b.png", is_image=True)
text2 = _uc_doc("docs", "c.md")
first = _uc_doc("docs", "a.png", is_image=True)
merged = merge_cited_docs([], [text1, second, text2, first], deflected=False)
assert merged == [second, first] # suggestion order, images only
# With a read doc present, the reads come first, then the image
# docs in suggestion order.
read = _uc_doc("docs", "read.md")
merged = merge_cited_docs([read], [second, first], deflected=False)
assert merged == [read, second, first]
def test_merge_cited_docs_read_order_preserved_for_mixed_read_docs() -> None:
"""(f) Read order is preserved for mixed read text docs and
duplicate reads collapse (dedupe by ``(source, path)``, first
occurrence wins) — with the suggested image doc appended AFTER
the reads."""
a = _uc_doc("docs", "a.md")
b = _uc_doc("docs", "b.md")
img = _uc_doc("docs", "pic.png", is_image=True)
merged = merge_cited_docs([b, a, b], [img, a], deflected=False)
assert merged == [b, a, img]
# A duplicate image read collapses too (once, at its read
# position) — the suggestion merge adds nothing for it.
merged = merge_cited_docs([img, img, a], [img], deflected=False)
assert merged == [img, a]
# ---------------------------------------------------------------------------
# Task 06 — the answer figure: live render, persistence, restore, shared
#
# House-style frontend source contracts (the phase-122/123 pattern —
# the browser-level scenario is task 07's isolated E2E): the chat
# page's ``appendAnswerImage`` helper (lazy img, the public bytes
# route, the visible caption, the collapse-on-error rule, the chip
# navigation affordance), its three wiring points (the ``done``
# handler BEFORE the chip row, the ``rememberBrainTurn`` meta key, the
# ``renderStoredMessage`` restore BEFORE the chip row), the shared
# page's NON-INTERACTIVE local copy (zero controls), and the
# answer-scale themed CSS (token-only, the 320px cap).
import re # noqa: E402
from pathlib import Path # noqa: E402
FRONTEND = Path(__file__).resolve().parents[2] / "frontend" # noqa: E402
APP_JS = FRONTEND / "assets" / "app.js" # noqa: E402
SHARED_JS = FRONTEND / "assets" / "shared.js" # noqa: E402
STYLES_CSS = FRONTEND / "assets" / "styles.css" # noqa: E402
def _function_body(js: str, header: str) -> str:
"""The full text of the function whose header is ``header`` — from
the header to its brace-matched closing ``}`` (the
``test_image_documents.py`` house helper; the pinned functions' code
carries balanced braces)."""
start = js.index(header)
body_open = js.index("{", start)
depth = 0
for j in range(body_open, len(js)):
if js[j] == "{":
depth += 1
elif js[j] == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError(f"unbalanced braces for {header!r}")
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _shared_js() -> str:
return SHARED_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def test_answer_image_helper_builds_the_lazy_capturing_figure() -> None:
"""The chat page's ``appendAnswerImage`` (the visual sibling of
``appendSourceImageFigure`` — the house createElement style, no
HTML strings): the figure anchors on the chip's navigation
affordance (the ``documentUrl`` href + the phase-26 same-page
modal), the img is LAZY on the public bytes route, alt + the
visible caption start at the TITLE and swap to the document's
SUMMARY via the SAME ``fetchContentSummary`` pattern (fails soft —
the title stands), and a FAILED IMAGE LOAD removes the whole
figure (the collapse rule — never a broken icon)."""
body = _function_body(_js(), "function appendAnswerImage(wrap, ref) {")
assert 'fig.className = "answer-image";' in body
assert 'fig.href = documentUrl(ref.source, ref.path, "/");' in body
assert "openDocumentModal(ref.source, ref.path, fig, \"/\")" in body
assert "e.preventDefault();" in body # no new tab (phase 26)
assert 'img.className = "answer-image-img";' in body
assert "img.src = ref.image_url;" in body # the public bytes route
assert 'img.loading = "lazy";' in body # A4: lazy-loaded
assert "img.alt = ref.title || label;" in body # title first (always visible)
assert 'caption.className = "answer-image-caption";' in body
assert "caption.textContent = ref.title || label;" in body # visible caption
assert "fetchContentSummary(ref.source, ref.path)" in body
assert "img.alt = summary;" in body # the swap once it settles
assert "caption.textContent = summary;" in body
assert "if (!fig.isConnected) return;" in body # collapsed/cleared → no swap
assert "fig.remove();" in body # the collapse-on-error rule
# The guard: no ref (or a malformed one) → no figure DOM at all.
assert "!ref" in body
assert 'typeof ref.image_url !== "string"' in body
def test_done_handler_renders_the_figure_before_the_chips() -> None:
"""A4 position, live path: the ``done`` handler appends the figure
to ``.msg-body`` right BEFORE ``appendSources`` creates the chip
row (below the text, above the chips) — and only when the frame
carries the key (a frame without it renders byte-identically,
no figure DOM)."""
js = _js()
call = "if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);"
assert call in js
done = js.index('} else if (ev.type === "done") {')
next_branch = js.index('} else if (ev.type === "tool_result") {')
handler = js[done:next_branch]
assert call in handler
assert handler.index(call) < handler.index("appendSources(wrap, ev.sources);")
def test_done_handler_persists_the_answer_image_meta() -> None:
"""The done-time save point carries the key (the house
undefined-drops-the-key pattern — the ``related`` line is the
precedent): image-less / pre-phase turns store byte-identical
records; a record WITH the key round-trips through the task-04
boundary (``ChatMessage.answer_image``)."""
js = _js()
done = js.index('} else if (ev.type === "done") {')
next_branch = js.index('} else if (ev.type === "tool_result") {')
handler = js[done:next_branch]
assert "answer_image: ev.answer_image || undefined," in handler
def test_stored_message_restore_renders_the_figure_before_the_chips() -> None:
"""The refresh landing: ``renderStoredMessage``'s brain branch
renders ``m.answer_image`` through the SAME helper at the SAME A4
position (before the chip row) — a record without the key renders
byte-identically (pre-phase chats restore unchanged)."""
body = _function_body(_js(), "function renderStoredMessage(m) {")
call = "if (m.answer_image) appendAnswerImage(wrap, m.answer_image);"
assert call in body
assert body.index(call) < body.index("appendSources(wrap, m.sources);")
def test_shared_page_figure_is_non_interactive() -> None:
"""The shared page's local copy (the per-page duplication house
style): a PLAIN non-interactive ``<figure>`` (owner-locked zero
controls — no anchor, no href, no click handler), the LAZY img on
the PUBLIC bytes route (the guest view is faithful — the
phase-123 user-image contract), the title as the always-visible
caption (the content endpoint is user-gated — a guest fetch would
401; no fetch, the title stands), and the same collapse-on-error
rule. Wired where the brain branch appends sources (figure
BEFORE the chips)."""
shared = _shared_js()
body = _function_body(shared, "function addAnswerImage(wrap, ref) {")
assert 'document.createElement("figure")' in body # plain figure, no anchor
assert 'fig.className = "answer-image";' in body
assert 'document.createElement("a")' not in body # zero controls
assert ".href" not in body
assert 'addEventListener("click"' not in body
assert 'img.className = "answer-image-img";' in body
assert "img.src = ref.image_url;" in body # the public bytes route
assert 'img.loading = "lazy";' in body
assert "img.alt = title;" in body
assert 'caption.className = "answer-image-caption";' in body
assert "caption.textContent = title;" in body
assert "fig.remove()" in body # the collapse-on-error rule
assert "fetchContentSummary" not in body # user-gated endpoint — no guest fetch
# The brain branch wiring: figure BEFORE the chips.
msg = _function_body(shared, "function renderSharedMessage(m) {")
assert "addAnswerImage(wrap, m.answer_image);" in msg
assert msg.index("addAnswerImage(wrap, m.answer_image);") < msg.index(
"addSources(wrap, m.sources);"
)
def test_answer_image_css_is_answer_scale_and_themed() -> None:
"""The ``.answer-image`` family (the ``.source-image`` language at
answer scale, the zero-literal invariant so the monochrome themes
carry it): the img is block, full-column-width safe, CAPPED at
320px, contain on the theme's surface with the bordered-image
treatment; the caption is the AA-safe muted ink (8.6:1 on the
row's page background); the hover underline is scoped to the chat
page's ANCHOR figure only (the shared page's plain ``<figure>`` is
out of scope by element type)."""
css = _css()
for selector in (".answer-image", ".answer-image-img", ".answer-image-caption"):
assert re.search(rf"^{re.escape(selector)} \{{", css, re.M), f"missing rule: {selector}"
img = re.search(r"^\.answer-image-img \{\n([\s\S]*?)\n\}", css, re.M)
assert img, "missing .answer-image-img rule"
img_body = img.group(1)
assert "max-height: 320px" in img_body # the answer-scale cap
assert "object-fit: contain" in img_body
assert "display: block" in img_body
assert "background: var(--surface)" in img_body # the theme's surface
assert "border: 1px solid var(--line)" in img_body
assert "max-width: 100%" in img_body
cap = re.search(r"^\.answer-image-caption \{\n([\s\S]*?)\n\}", css, re.M)
assert cap, "missing .answer-image-caption rule"
assert "color: var(--ink-soft)" in cap.group(1) # AA: 8.6:1 on --bg
base = re.search(r"^\.answer-image \{\n([\s\S]*?)\n\}", css, re.M)
assert base and "max-width: 100%" in base.group(1) # full bubble width
hover = re.search(r"^a\.answer-image:hover \{([^}]*)\}", css, re.M)
assert hover and "text-decoration: underline" in hover.group(1), (
"the chat-page anchor figure keeps the flat-underline hover"
)
# Zero literals: the whole phase-124 block is token-only (no hex
# colors — the monochrome theme grays it automatically).
start = css.index("/* Phase 124 (task 06")
end = css.index("/* Phase 113 (task 02): the related-docs row")
assert start != -1 and end > start
assert re.search(r"#[0-9a-fA-F]{3,6}\b", css[start:end]) is None