"""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. Phase 182 (task 03) pin update, deliberate: the flag pin passes ``answer_text=False`` explicitly — with ``answer_text`` ON (the shipped default) the offer additionally carries ``show_text`` (pinned in ``tests/unit/test_agent.py``); the ``answer_images`` gate itself is unchanged.""" off = agent_tools(_settings(answer_text=False)) assert [t["function"]["name"] for t in off] == ["ls", "read", "grep"] assert off == AGENT_TOOLS # element-for-element assert off is not AGENT_TOOLS # a fresh list, never the constant itself 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). Phase 182 (task 03) pin update, deliberate: ``answer_text=False`` isolates the ``answer_images`` gate — with ``answer_text`` ON (the shipped default) ``show_text`` rides LAST (pinned in ``tests/unit/test_agent.py``).""" on = agent_tools(_settings(answer_images=True, answer_text=False)) assert [t["function"]["name"] for t in on] == ["ls", "read", "grep", "show_image"] assert on[:3] == AGENT_TOOLS # the base three byte-identical assert on[3] == SHOW_IMAGE_TOOL 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. Phase 182 (task 03) pin update, deliberate: both apps pass ``answer_text=False`` to isolate the ``answer_images`` gate (with the shipped default ON, ``show_text`` additionally rides last — pinned in ``tests/unit/test_agent.py``).""" class _ScriptedLLM: """One canned content-only round; records the ``tools`` every 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(answer_text=False))) assert len(llm_off.requests) == 1 assert llm_off.requests[0][1] == AGENT_TOOLS # off: element-for-element pre-124 llm_on = _ScriptedLLM() asyncio.run(_consume(llm_on, _settings(answer_images=True, answer_text=False))) sent = llm_on.requests[0][1] assert sent is not None # the grounded turn (max_rounds > 0) offers tools assert [t["function"]["name"] for t in sent] == [ "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 ````) exactly when it is on.""" off_settings = _settings() off = _plan3(0.9, off_settings) assert off.deflected is False # Phase 182 (task 04): the reconstruction mirrors the OTHER display # clause's flag too (BOR_ANSWER_TEXT, default ON) so this pin stays # about answer_images alone — the show_text clause is pinned in # tests/unit/test_prompts.py. assert off.system_prompt == build_high_prompt( [_P3_DOC], show_text=off_settings.answer_text ) assert SHOW_IMAGE_CLAUSE not in off.system_prompt on_settings = _settings(answer_images=True) on = _plan3(0.9, on_settings) assert on.deflected is False assert on.system_prompt == build_high_prompt( [_P3_DOC], show_image=True, show_text=on_settings.answer_text ) assert SHOW_IMAGE_CLAUSE in on.system_prompt assert on.system_prompt.index("") < on.system_prompt.index(SHOW_IMAGE_CLAUSE) assert on.system_prompt.index(SHOW_IMAGE_CLAUSE) < on.system_prompt.index("") 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 "" 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 ``
`` (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 ``
`` 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