diff --git a/.agents/phases/todo/124_image_chips_and_answers/00_phase.md b/.agents/phases/complete/124_image_chips_and_answers/00_phase.md
similarity index 100%
rename from .agents/phases/todo/124_image_chips_and_answers/00_phase.md
rename to .agents/phases/complete/124_image_chips_and_answers/00_phase.md
diff --git a/.agents/phases/todo/124_image_chips_and_answers/01_answer_images_toggle.md b/.agents/phases/complete/124_image_chips_and_answers/01_answer_images_toggle.md
similarity index 100%
rename from .agents/phases/todo/124_image_chips_and_answers/01_answer_images_toggle.md
rename to .agents/phases/complete/124_image_chips_and_answers/01_answer_images_toggle.md
diff --git a/.agents/phases/todo/124_image_chips_and_answers/02_show_image_tool.md b/.agents/phases/complete/124_image_chips_and_answers/02_show_image_tool.md
similarity index 100%
rename from .agents/phases/todo/124_image_chips_and_answers/02_show_image_tool.md
rename to .agents/phases/complete/124_image_chips_and_answers/02_show_image_tool.md
diff --git a/.agents/phases/todo/124_image_chips_and_answers/03_prompt_clause.md b/.agents/phases/complete/124_image_chips_and_answers/03_prompt_clause.md
similarity index 100%
rename from .agents/phases/todo/124_image_chips_and_answers/03_prompt_clause.md
rename to .agents/phases/complete/124_image_chips_and_answers/03_prompt_clause.md
diff --git a/.agents/phases/todo/124_image_chips_and_answers/04_frame_and_persistence.md b/.agents/phases/complete/124_image_chips_and_answers/04_frame_and_persistence.md
similarity index 100%
rename from .agents/phases/todo/124_image_chips_and_answers/04_frame_and_persistence.md
rename to .agents/phases/complete/124_image_chips_and_answers/04_frame_and_persistence.md
diff --git a/.agents/phases/todo/124_image_chips_and_answers/05_auto_cite_image_docs.md b/.agents/phases/complete/124_image_chips_and_answers/05_auto_cite_image_docs.md
similarity index 100%
rename from .agents/phases/todo/124_image_chips_and_answers/05_auto_cite_image_docs.md
rename to .agents/phases/complete/124_image_chips_and_answers/05_auto_cite_image_docs.md
diff --git a/.agents/phases/todo/124_image_chips_and_answers/06_chat_ui_answer_image.md b/.agents/phases/complete/124_image_chips_and_answers/06_chat_ui_answer_image.md
similarity index 100%
rename from .agents/phases/todo/124_image_chips_and_answers/06_chat_ui_answer_image.md
rename to .agents/phases/complete/124_image_chips_and_answers/06_chat_ui_answer_image.md
diff --git a/.agents/phases/todo/124_image_chips_and_answers/07_answer_image_tests.md b/.agents/phases/complete/124_image_chips_and_answers/07_answer_image_tests.md
similarity index 100%
rename from .agents/phases/todo/124_image_chips_and_answers/07_answer_image_tests.md
rename to .agents/phases/complete/124_image_chips_and_answers/07_answer_image_tests.md
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__00_phase.a1.err b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__00_phase.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__00_phase.a1.md b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__00_phase.a1.md
new file mode 100644
index 0000000..3db264b
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__00_phase.a1.md
@@ -0,0 +1,13 @@
+All green. Final report:
+
+**Phase 124 final verification pass — all criteria verified, no defects found (nothing to fix)**
+- Verified the implemented phase (all 7 tasks done, changes in working tree): `BOR_ANSWER_IMAGES` toggle, `show_image` tool + refusals + `holder.shown_images`, toggle-gated prompt clause, `answer_image` frame field (None-omitting serializer) + `ChatMessage` persistence, `merge_cited_docs` auto-cite bug fix, answer-figure UI (live/restore/shared/CSS), mock triggers + E2E suite. Also confirmed the bytes route → public change matches the phase-123 shared-chat precedent (task 06 design).
+- `uv run pytest --cov=app --cov-report=term-missing` → **2848 passed**, TOTAL **99%** (>90% gate)
+- `bash .agents/validate.sh` → **validation OK** (tests + coverage + ruff + pyright)
+- `uv run ruff check . && uv run pyright` → clean, 0 errors
+- `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` (isolated) → **5/5 passed**
+- Mock-inert spot checks: `tests/e2e/test_image_documents.py` 4/4, `tests/e2e/test_agent_document_tools.py` 4/4
+
+**Completion criteria:** ① zero-read suggested-tier image doc chips with figure — verified (unit matrix, integration `test_zero_read_grounded_turn_auto_cites_suggested_image_doc`, E2E bug-fix flows); ② show_image → answer figure below text/above chips, second call refused, persists + restore + shared — verified (unit handler matrix, integration round-trip, E2E feature flow); ③ default-off byte-identical (3 tools, no clause, no key) — verified (unit + integration + E2E toggle-off); ④ full suite green / >90% / lint clean — verified above; ⑤ commit + phase move — harness responsibility (changes left uncommitted in working tree, no live-infrastructure changes made).
+
+**Next pending phase:** none — `todo/` holds only this phase; the pipeline is clear once the harness moves it.
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__00_phase.a1.validate b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__00_phase.a1.validate
new file mode 100644
index 0000000..e999c31
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__00_phase.a1.validate
@@ -0,0 +1,109 @@
+........................................................................ [ 2%]
+........................................................................ [ 5%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 15%]
+........................................................................ [ 17%]
+........................................................................ [ 20%]
+........................................................................ [ 22%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 30%]
+........................................................................ [ 32%]
+........................................................................ [ 35%]
+........................................................................ [ 37%]
+........................................................................ [ 40%]
+........................................................................ [ 42%]
+........................................................................ [ 45%]
+........................................................................ [ 48%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 58%]
+........................................................................ [ 60%]
+........................................................................ [ 63%]
+........................................................................ [ 65%]
+........................................................................ [ 68%]
+........................................................................ [ 70%]
+........................................................................ [ 73%]
+........................................................................ [ 75%]
+........................................................................ [ 78%]
+........................................................................ [ 80%]
+........................................................................ [ 83%]
+........................................................................ [ 85%]
+........................................................................ [ 88%]
+........................................................................ [ 91%]
+........................................................................ [ 93%]
+........................................................................ [ 96%]
+........................................................................ [ 98%]
+........................................ [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 262 1 99%
+app/api/chat_images.py 50 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 179 1 99%
+app/api/git_sources.py 241 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 139 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 256 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 68 0 100%
+app/models.py 131 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 374 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 123 0 100%
+app/rag/git_sources.py 38 0 100%
+app/rag/importer.py 316 16 95%
+app/rag/llm.py 244 1 99%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 106 0 100%
+app/rag/retriever.py 248 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 48 0 100%
+app/schemas.py 400 0 100%
+--------------------------------------------------
+TOTAL 4669 29 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__01_answer_images_toggle.a1.err b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__01_answer_images_toggle.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__01_answer_images_toggle.a1.md b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__01_answer_images_toggle.a1.md
new file mode 100644
index 0000000..89360d6
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__01_answer_images_toggle.a1.md
@@ -0,0 +1,19 @@
+That's an empty harness session log — not mine, leaving it untouched. Task 01 is complete and all gates are green.
+
+## Report
+
+**Implemented (task 01 — `BOR_ANSWER_IMAGES` feature toggle):**
+- `app/config.py`: new `Settings.answer_images: bool = False` in a new "Answer images (phase 124)" block after `chat_image_max_mb` — docstring per task spec (gates the ANSWER side only; INDEPENDENT of `images`/`BOR_IMAGES` per LOCKED A2; off = no `show_image` tool, no prompt clause, no `answer_image` frame key)
+- `.env.example`: commented `# BOR_ANSWER_IMAGES=0` + "off by default; enable alongside BOR_IMAGES" comment beside the existing image block
+- `tests/unit/test_image_chips_and_answers.py` (new, created per phase overview for tasks 01–05): default-off (unset/`0`/`false`), truthy parsing (`1`/`true`, mirroring the `stream_thinking` bool-test precedent), and bidirectional independence from `images`
+- Per task instruction, no `answer_images` key in `GET /api/config` (UI is purely reactive)
+
+**Test / lint / coverage results:**
+- `uv run pytest` → **2799 passed** (51 s, full unit + integration suite)
+- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate)
+- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings
+- New-file run: `uv run pytest tests/unit/test_image_chips_and_answers.py tests/unit/test_config.py -v --no-cov` → 63 passed
+
+**Decisions / deviations:** none — followed the task file exactly; verified via grep no other code reads the field yet (off-state byte-identical). Left changes uncommitted per harness protocol.
+
+**Next pending task:** `02_show_image_tool.md` (`agent_tools` gating + `show_image` handler).
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__01_answer_images_toggle.a1.validate b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__01_answer_images_toggle.a1.validate
new file mode 100644
index 0000000..7eb295c
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__01_answer_images_toggle.a1.validate
@@ -0,0 +1,108 @@
+........................................................................ [ 2%]
+........................................................................ [ 5%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 15%]
+........................................................................ [ 18%]
+........................................................................ [ 20%]
+........................................................................ [ 23%]
+........................................................................ [ 25%]
+........................................................................ [ 28%]
+........................................................................ [ 30%]
+........................................................................ [ 33%]
+........................................................................ [ 36%]
+........................................................................ [ 38%]
+........................................................................ [ 41%]
+........................................................................ [ 43%]
+........................................................................ [ 46%]
+........................................................................ [ 48%]
+........................................................................ [ 51%]
+........................................................................ [ 54%]
+........................................................................ [ 56%]
+........................................................................ [ 59%]
+........................................................................ [ 61%]
+........................................................................ [ 64%]
+........................................................................ [ 66%]
+........................................................................ [ 69%]
+........................................................................ [ 72%]
+........................................................................ [ 74%]
+........................................................................ [ 77%]
+........................................................................ [ 79%]
+........................................................................ [ 82%]
+........................................................................ [ 84%]
+........................................................................ [ 87%]
+........................................................................ [ 90%]
+........................................................................ [ 92%]
+........................................................................ [ 95%]
+........................................................................ [ 97%]
+............................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 248 1 99%
+app/api/chat_images.py 50 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 179 1 99%
+app/api/git_sources.py 241 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 139 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 256 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 68 0 100%
+app/models.py 131 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 349 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 123 0 100%
+app/rag/git_sources.py 38 0 100%
+app/rag/importer.py 316 16 95%
+app/rag/llm.py 244 1 99%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 102 0 100%
+app/rag/retriever.py 248 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 48 0 100%
+app/schemas.py 390 0 100%
+--------------------------------------------------
+TOTAL 4616 29 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__02_show_image_tool.a1.err b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__02_show_image_tool.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__02_show_image_tool.a1.md b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__02_show_image_tool.a1.md
new file mode 100644
index 0000000..e9fbe2d
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__02_show_image_tool.a1.md
@@ -0,0 +1,9 @@
+All gates green. Final report:
+
+**Task 02 — `show_image` agent tool (toggle-gated): complete**
+
+- `app/rag/agent.py`: new `SHOW_IMAGE_TOOL` constant (gate copy per LOCKED A5 in the description) + `agent_tools(settings)` returning a fresh `[...AGENT_TOOLS]` (+ `show_image` only when `answer_images` on); new refusal constants `MISSING_SHOW_ARGS` / `NOT_AN_IMAGE_DOC` (arg-echoed) / `ALREADY_SHOWING`; `show_image` branch in `_execute_tool` (after `read`, before the unknown-tool fallback) — missing-arg → refusal, unresolvable → phase-72 `_no_document_refusal`, text doc → refused (never converted to read), second call → `ALREADY_SHOWING` (A3, server-enforced), success → `holder.shown_images.append` + `tool_calls += 1` + confirming line and the exact read-style image doc block (`Document …` / `date:` / `IMAGE_DOC_MARKER` / description, no truncation machinery); `AgentHolder.shown_images` field + docstring; `run_agent` wiring now `agent_tools(settings) if max_rounds > 0 else None` (kill switch unchanged). `AGENT_TOOLS` itself is byte-identical (verified by diff + all existing agent pins pass).
+- `tests/unit/test_image_chips_and_answers.py`: 9 new tests — offer gating (off = 3 base, element-for-element + fresh-list mutation safety; on = +`show_image` with gate copy), tool-definition shape, `run_agent` offer wiring on/off, refusal-text pins, and the full handler matrix (a–e) against real Postgres rows.
+- Results: `uv run pytest --cov=app` → 2809 passed, TOTAL **99%** (>90%); `uv run ruff check .` → clean; `uv run pyright` → 0 errors; regression `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → 4 passed.
+- Deviations: none; task 02 is unit-scoped (integration pins are task 04, E2E is task 07).
+- Next pending task: `03_prompt_clause.md` (the toggle-gated `` clause in the HIGH prompt).
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__02_show_image_tool.a1.validate b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__02_show_image_tool.a1.validate
new file mode 100644
index 0000000..088e994
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__02_show_image_tool.a1.validate
@@ -0,0 +1,109 @@
+........................................................................ [ 2%]
+........................................................................ [ 5%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 15%]
+........................................................................ [ 17%]
+........................................................................ [ 20%]
+........................................................................ [ 23%]
+........................................................................ [ 25%]
+........................................................................ [ 28%]
+........................................................................ [ 30%]
+........................................................................ [ 33%]
+........................................................................ [ 35%]
+........................................................................ [ 38%]
+........................................................................ [ 41%]
+........................................................................ [ 43%]
+........................................................................ [ 46%]
+........................................................................ [ 48%]
+........................................................................ [ 51%]
+........................................................................ [ 53%]
+........................................................................ [ 56%]
+........................................................................ [ 58%]
+........................................................................ [ 61%]
+........................................................................ [ 64%]
+........................................................................ [ 66%]
+........................................................................ [ 69%]
+........................................................................ [ 71%]
+........................................................................ [ 74%]
+........................................................................ [ 76%]
+........................................................................ [ 79%]
+........................................................................ [ 82%]
+........................................................................ [ 84%]
+........................................................................ [ 87%]
+........................................................................ [ 89%]
+........................................................................ [ 92%]
+........................................................................ [ 94%]
+........................................................................ [ 97%]
+........................................................................ [ 99%]
+. [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 248 1 99%
+app/api/chat_images.py 50 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 179 1 99%
+app/api/git_sources.py 241 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 139 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 256 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 68 0 100%
+app/models.py 131 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 374 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 123 0 100%
+app/rag/git_sources.py 38 0 100%
+app/rag/importer.py 316 16 95%
+app/rag/llm.py 244 1 99%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 102 0 100%
+app/rag/retriever.py 248 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 48 0 100%
+app/schemas.py 390 0 100%
+--------------------------------------------------
+TOTAL 4641 29 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a1.err b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a1.md b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a1.md
new file mode 100644
index 0000000..77fd6f8
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a1.md
@@ -0,0 +1,16 @@
+Task 03 complete. All criteria verified.
+
+**Implemented**
+- `app/rag/prompts.py`: new `SHOW_IMAGE_CLAUSE` constant (all four A5 gate elements: GENERATED-description nature / "actually wants to see" bar / one image per answer / answer fully in text); `build_high_prompt` gained `show_image: bool = False` — the clause rides inside `` (after the three-tool copy, before ``); `TOOLS_SECTION` and the toggle-off output untouched.
+- `app/api/chat.py`: the single HIGH-prompt build site (`plan_turn`) passes `show_image=settings.answer_images`; deflection prompt untouched.
+- Tests: 6 new in `tests/unit/test_prompts.py` (byte-identity off, structural equation on, four phrases, clause HIGH-only + `build_deflect_prompt` has no flag, empty-docs build, all-sections shape) + 2 `plan_turn` wiring pins in `tests/unit/test_image_chips_and_answers.py` (grounded plan carries the clause exactly when the toggle is on; deflected never).
+
+**Results**
+- `uv run pytest --cov=app --cov-report=term-missing` → 2817 passed, 0 failed; TOTAL 99% (prompts.py 100%, chat.py 99%)
+- `uv run ruff check . && uv run pyright` → clean (fixed one import-sort + one pyright `**kwargs` inference)
+- Pre-existing `test_prompts.py`/`test_prompt_lock.py` pins pass unmodified (62 passed)
+
+**Decisions**
+- Clause constant carries no trailing newline; the built section inserts one before `` (house style: closing tag on its own line) — literal task construction otherwise.
+
+**Next pending task:** `04_frame_and_persistence.md` (ChatDoneEvent/ChatMessage `answer_image` + done-frame wiring).
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a1.validate b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a1.validate
new file mode 100644
index 0000000..43f1966
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a1.validate
@@ -0,0 +1,109 @@
+........................................................................ [ 2%]
+........................................................................ [ 5%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 15%]
+........................................................................ [ 17%]
+........................................................................ [ 20%]
+........................................................................ [ 23%]
+........................................................................ [ 25%]
+........................................................................ [ 28%]
+........................................................................ [ 30%]
+........................................................................ [ 33%]
+........................................................................ [ 35%]
+........................................................................ [ 38%]
+........................................................................ [ 40%]
+........................................................................ [ 43%]
+........................................................................ [ 46%]
+........................................................................ [ 48%]
+........................................................................ [ 51%]
+........................................................................ [ 53%]
+........................................................................ [ 56%]
+........................................................................ [ 58%]
+........................................................................ [ 61%]
+........................................................................ [ 63%]
+........................................................................ [ 66%]
+........................................................................ [ 69%]
+........................................................................ [ 71%]
+........................................................................ [ 74%]
+........................................................................ [ 76%]
+........................................................................ [ 79%]
+........................................................................ [ 81%]
+........................................................................ [ 84%]
+........................................................................ [ 86%]
+........................................................................ [ 89%]
+........................................................................ [ 92%]
+........................................................................ [ 94%]
+........................................................................ [ 97%]
+........................................................................ [ 99%]
+......... [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 248 1 99%
+app/api/chat_images.py 50 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 179 1 99%
+app/api/git_sources.py 241 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 139 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 256 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 68 0 100%
+app/models.py 131 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 374 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 123 0 100%
+app/rag/git_sources.py 38 0 100%
+app/rag/importer.py 316 16 95%
+app/rag/llm.py 244 1 99%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 106 0 100%
+app/rag/retriever.py 248 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 48 0 100%
+app/schemas.py 390 0 100%
+--------------------------------------------------
+TOTAL 4645 29 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a2.err b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a2.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a2.md b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a2.md
new file mode 100644
index 0000000..1e0f543
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a2.md
@@ -0,0 +1,12 @@
+Everything is green. The previous attempt's code was intact; the rejection was a child-run reporting glitch, not a test/lint failure. All gates re-verified.
+
+**Report**
+
+- Implemented: `SHOW_IMAGE_CLAUSE` constant in `app/rag/prompts.py` (all four A5 gate elements); `build_high_prompt` gained `show_image: bool = False` — clause lands inside `` after the three-tool copy, before ``; `TOOLS_SECTION` and the toggle-off output byte-identical.
+- `app/api/chat.py`: the single HIGH-prompt build site (`plan_turn`) passes `show_image=settings.answer_images`; deflection prompt untouched.
+- Tests: 6 new in `tests/unit/test_prompts.py` (byte-identity off, structural equation on, four stable phrases, clause HIGH-only + `build_deflect_prompt` takes no flag, empty-docs build, full-sections shape) + 2 `plan_turn` wiring pins in `tests/unit/test_image_chips_and_answers.py` (grounded plan carries the clause exactly when the toggle is on; deflected never).
+- `uv run pytest --cov=app --cov-report=term-missing` → 2817 passed, 0 failed; TOTAL 99% (prompts.py 100%, chat.py 99%).
+- `uv run ruff check . && uv run pyright` → all checks passed, 0 errors.
+- Pre-existing `test_prompts.py`/`test_prompt_lock.py` pins pass unmodified (62 passed).
+- Decisions: clause constant carries no trailing newline; the built section inserts one before `` (house style: closing tag on its own line) — literal task construction otherwise.
+- Next pending task: `04_frame_and_persistence.md` (ChatDoneEvent/ChatMessage `answer_image` + done-frame wiring).
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a2.validate b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a2.validate
new file mode 100644
index 0000000..43f1966
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__03_prompt_clause.a2.validate
@@ -0,0 +1,109 @@
+........................................................................ [ 2%]
+........................................................................ [ 5%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 15%]
+........................................................................ [ 17%]
+........................................................................ [ 20%]
+........................................................................ [ 23%]
+........................................................................ [ 25%]
+........................................................................ [ 28%]
+........................................................................ [ 30%]
+........................................................................ [ 33%]
+........................................................................ [ 35%]
+........................................................................ [ 38%]
+........................................................................ [ 40%]
+........................................................................ [ 43%]
+........................................................................ [ 46%]
+........................................................................ [ 48%]
+........................................................................ [ 51%]
+........................................................................ [ 53%]
+........................................................................ [ 56%]
+........................................................................ [ 58%]
+........................................................................ [ 61%]
+........................................................................ [ 63%]
+........................................................................ [ 66%]
+........................................................................ [ 69%]
+........................................................................ [ 71%]
+........................................................................ [ 74%]
+........................................................................ [ 76%]
+........................................................................ [ 79%]
+........................................................................ [ 81%]
+........................................................................ [ 84%]
+........................................................................ [ 86%]
+........................................................................ [ 89%]
+........................................................................ [ 92%]
+........................................................................ [ 94%]
+........................................................................ [ 97%]
+........................................................................ [ 99%]
+......... [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 248 1 99%
+app/api/chat_images.py 50 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 179 1 99%
+app/api/git_sources.py 241 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 139 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 256 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 68 0 100%
+app/models.py 131 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 374 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 123 0 100%
+app/rag/git_sources.py 38 0 100%
+app/rag/importer.py 316 16 95%
+app/rag/llm.py 244 1 99%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 106 0 100%
+app/rag/retriever.py 248 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 48 0 100%
+app/schemas.py 390 0 100%
+--------------------------------------------------
+TOTAL 4645 29 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a1.err b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a1.md b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a1.md
new file mode 100644
index 0000000..a02141e
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a1.md
@@ -0,0 +1,16 @@
+All green. Final report:
+
+**Implemented (task 04 — frame + persistence):**
+- `app/schemas.py`: `ChatDoneEvent.answer_image: SourceRef | None` + wrap serializer dropping the key when `None` (byte-identical-off); `ChatMessage.answer_image` (brain records; docstring pins it distinct from the phase-123 user `image` path) + extended the wrap serializer to drop it when `None`
+- `app/api/chat.py`: done frame passes `source_ref_with_image(holder.shown_images[0]) if holder.shown_images else None`
+- `tests/conftest.py`: `BOR_ANSWER_IMAGES` leak guard (phase-106/122 house pattern)
+- 14 unit tests (`test_image_chips_and_answers.py`): serializer omission/presence, pre-phase parse, full-ref, phase-83 bounds; 4 integration chat tests (show_image turn → `answer_image` + cited source + 4-tool offer; toggle-off → 3 tools + no key; zero-tool + deflected-toggle-on → no key on raw wire); 3 chats tests (201 round-trip incl. raw JSONB — phase-113 422 pin, no-key unchanged, shared-serve fidelity)
+
+**Verification:**
+- `uv run pytest --cov=app --cov-report=term-missing` → 2834 passed, TOTAL **99%** (>90%; `schemas.py` 100%, `chat.py` 99% — sole miss is pre-existing line 521)
+- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
+- Regression check: `tests/e2e/test_share_chat.py` (4 passed) + `tests/e2e/test_image_documents.py` (4 passed) in isolation
+
+**Decisions:** extended the single existing `ChatMessage` wrap serializer (Pydantic allows one per model) so pre-phase records round-trip byte-identically — existing `FULL_BRAIN`/`_expect` pins pass unmodified. Test (a) scripts read+show_image (cited tier = read docs until task 05's auto-cite); (b) omits the show_image call per the phase overview ("no such call occurs"). No live infra touched; no commits made.
+
+**Next pending task:** `.agents/phases/todo/124_image_chips_and_answers/05_auto_cite_image_docs.md`
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a1.validate b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a1.validate
new file mode 100644
index 0000000..ec6c5fd
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a1.validate
@@ -0,0 +1,109 @@
+........................................................................ [ 2%]
+........................................................................ [ 5%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 15%]
+........................................................................ [ 17%]
+........................................................................ [ 20%]
+........................................................................ [ 22%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 30%]
+........................................................................ [ 33%]
+........................................................................ [ 35%]
+........................................................................ [ 38%]
+........................................................................ [ 40%]
+........................................................................ [ 43%]
+........................................................................ [ 45%]
+........................................................................ [ 48%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 58%]
+........................................................................ [ 60%]
+........................................................................ [ 63%]
+........................................................................ [ 66%]
+........................................................................ [ 68%]
+........................................................................ [ 71%]
+........................................................................ [ 73%]
+........................................................................ [ 76%]
+........................................................................ [ 78%]
+........................................................................ [ 81%]
+........................................................................ [ 83%]
+........................................................................ [ 86%]
+........................................................................ [ 88%]
+........................................................................ [ 91%]
+........................................................................ [ 94%]
+........................................................................ [ 96%]
+........................................................................ [ 99%]
+.......................... [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 249 1 99%
+app/api/chat_images.py 50 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 179 1 99%
+app/api/git_sources.py 241 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 139 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 256 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 68 0 100%
+app/models.py 131 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 374 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 123 0 100%
+app/rag/git_sources.py 38 0 100%
+app/rag/importer.py 316 16 95%
+app/rag/llm.py 244 1 99%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 106 0 100%
+app/rag/retriever.py 248 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 48 0 100%
+app/schemas.py 400 0 100%
+--------------------------------------------------
+TOTAL 4656 29 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a2.err b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a2.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a2.md b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a2.md
new file mode 100644
index 0000000..b6cd8c3
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a2.md
@@ -0,0 +1,17 @@
+Everything is green. The prior rejection was a harness report-format issue, not a code failure — all work was already in the working tree and remains intact. Final verification complete.
+
+**Implemented (task 04 — the `answer_image` done frame + saved-chat persistence):**
+- `app/schemas.py`: `ChatDoneEvent.answer_image: SourceRef | None` + a `model_serializer(mode="wrap")` that drops the key when `None` (byte-identical-off — absent, never `null`). `ChatMessage.answer_image` (brain records; docstring marks it distinct from the phase-123 user `image` path) + extended the wrap serializer to drop it when `None`.
+- `app/api/chat.py`: done frame passes `source_ref_with_image(holder.shown_images[0]) if holder.shown_images else None`.
+- `tests/conftest.py`: `BOR_ANSWER_IMAGES` leak guard (phase-106/122 house pattern).
+- 14 unit tests + 4 chat-integration tests + 3 chats-integration tests (phase-113 422 round-trip pin, no-key unchanged, shared-serve fidelity).
+
+**Results (all re-run this session):**
+- `uv run pytest --cov=app --cov-report=term-missing` → **2834 passed**, TOTAL **99%** (>90%; `schemas.py` 100%)
+- `uv run ruff check .` → All checks passed
+- `uv run pyright` → 0 errors, 0 warnings
+- task-04 files in isolation → 147 passed
+
+**Notable decisions:** extended the single existing `ChatMessage` wrap serializer (Pydantic allows one per model) so pre-phase records round-trip byte-identically; existing `FULL_BRAIN`/`_expect` pins pass unmodified. Test (a) scripts read+show_image (cited tier = read docs until task 05); (b) omits the show_image call per the phase overview ("no such call occurs"). No live infra touched; no commits made (left in working tree for the harness).
+
+**Next pending task:** `.agents/phases/todo/124_image_chips_and_answers/05_auto_cite_image_docs.md`
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a2.validate b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a2.validate
new file mode 100644
index 0000000..ec6c5fd
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__04_frame_and_persistence.a2.validate
@@ -0,0 +1,109 @@
+........................................................................ [ 2%]
+........................................................................ [ 5%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 15%]
+........................................................................ [ 17%]
+........................................................................ [ 20%]
+........................................................................ [ 22%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 30%]
+........................................................................ [ 33%]
+........................................................................ [ 35%]
+........................................................................ [ 38%]
+........................................................................ [ 40%]
+........................................................................ [ 43%]
+........................................................................ [ 45%]
+........................................................................ [ 48%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 58%]
+........................................................................ [ 60%]
+........................................................................ [ 63%]
+........................................................................ [ 66%]
+........................................................................ [ 68%]
+........................................................................ [ 71%]
+........................................................................ [ 73%]
+........................................................................ [ 76%]
+........................................................................ [ 78%]
+........................................................................ [ 81%]
+........................................................................ [ 83%]
+........................................................................ [ 86%]
+........................................................................ [ 88%]
+........................................................................ [ 91%]
+........................................................................ [ 94%]
+........................................................................ [ 96%]
+........................................................................ [ 99%]
+.......................... [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 249 1 99%
+app/api/chat_images.py 50 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 179 1 99%
+app/api/git_sources.py 241 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 139 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 256 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 68 0 100%
+app/models.py 131 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 374 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 123 0 100%
+app/rag/git_sources.py 38 0 100%
+app/rag/importer.py 316 16 95%
+app/rag/llm.py 244 1 99%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 106 0 100%
+app/rag/retriever.py 248 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 48 0 100%
+app/schemas.py 400 0 100%
+--------------------------------------------------
+TOTAL 4656 29 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a1.err b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a1.md b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a1.md
new file mode 100644
index 0000000..af10309
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a1.md
@@ -0,0 +1,11 @@
+Working tree intact, everything green. Task complete.
+
+**Report — Task 05: auto-cite suggested-tier image docs**
+
+- `app/api/chat.py`: new module-level `merge_cited_docs(read_docs, suggested_docs, deflected)` — phase-119 read-docs tier + the single documented exception: suggested-tier `is_image` docs auto-cited in suggestion order after reads, deduped by `(source, path)`; deflected → `[]`. Pipeline calls it; `cited_seen` derived from the result (related-tier exclusion and `record_docs` untouched). Comment block updated per spec.
+- Unit: 6 new tests in `tests/unit/test_image_chips_and_answers.py` covering cases (a)–(f) (zero-read image cited, read-image once at read position, text never auto-cited, deflected `[]`, two images in suggestion order, read order preserved/deduped).
+- Integration: `tests/integration/test_chat_api.py` — zero-read grounded turn with a seeded image doc (content==summary==description + question-overlapping chunk, phase-122 invariant) → `done.sources` carries the image ref with `image_url`, `related` excludes it, `query_log.sources` unchanged; text-only KB turn → `sources == []` byte-identical.
+- Tests: `uv run pytest` → **2842 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (chat.py 99%); `uv run ruff check . && uv run pyright` → **clean (0 errors)**.
+- Predecessor E2E regression checks (in isolation): `test_image_documents.py` 4 passed, `test_chat_image_questions.py` 5 passed — scripted `read` still valid, no double chips.
+- Notable: image-doc summaries carry no `Source:` pointer line (verbatim description, phase-122 task 03) — seed pin asserts the `` block instead.
+- Next pending task: `.agents/phases/todo/124_image_chips_and_answers/06_chat_ui_answer_image.md`.
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a1.validate b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a1.validate
new file mode 100644
index 0000000..befe810
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a1.validate
@@ -0,0 +1,109 @@
+........................................................................ [ 2%]
+........................................................................ [ 5%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 15%]
+........................................................................ [ 17%]
+........................................................................ [ 20%]
+........................................................................ [ 22%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 30%]
+........................................................................ [ 32%]
+........................................................................ [ 35%]
+........................................................................ [ 38%]
+........................................................................ [ 40%]
+........................................................................ [ 43%]
+........................................................................ [ 45%]
+........................................................................ [ 48%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 58%]
+........................................................................ [ 60%]
+........................................................................ [ 63%]
+........................................................................ [ 65%]
+........................................................................ [ 68%]
+........................................................................ [ 70%]
+........................................................................ [ 73%]
+........................................................................ [ 76%]
+........................................................................ [ 78%]
+........................................................................ [ 81%]
+........................................................................ [ 83%]
+........................................................................ [ 86%]
+........................................................................ [ 88%]
+........................................................................ [ 91%]
+........................................................................ [ 93%]
+........................................................................ [ 96%]
+........................................................................ [ 98%]
+.................................. [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 262 1 99%
+app/api/chat_images.py 50 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 179 1 99%
+app/api/git_sources.py 241 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 139 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 256 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 68 0 100%
+app/models.py 131 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 374 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 123 0 100%
+app/rag/git_sources.py 38 0 100%
+app/rag/importer.py 316 16 95%
+app/rag/llm.py 244 1 99%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 106 0 100%
+app/rag/retriever.py 248 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 48 0 100%
+app/schemas.py 400 0 100%
+--------------------------------------------------
+TOTAL 4669 29 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a2.err b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a2.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a2.md b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a2.md
new file mode 100644
index 0000000..2d6f212
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a2.md
@@ -0,0 +1,10 @@
+**Report — Task 05: auto-cite suggested-tier image docs (re-verified after harness rejection)**
+
+- The rejection was process-level ("child run ended without a clean final report") — the work was complete and green; no code defects were found. Working tree re-verified intact, all gates re-run from scratch.
+- `app/api/chat.py`: module-level `merge_cited_docs(read_docs, suggested_docs, deflected)` — phase-119 read-docs tier + the single documented exception: suggested-tier `is_image` docs auto-cited in suggestion order after reads, deduped by `(source, path)`; deflected → `[]`. Pipeline calls it; `cited_seen` derived from the result; `record_docs`/related-tier exclusion untouched.
+- Unit: 6 new helper-matrix tests in `tests/unit/test_image_chips_and_answers.py` (zero-read image cited, read-image once at read position, text never auto-cited, deflected `[]`, two images in suggestion order, read order preserved/deduped).
+- Integration: `tests/integration/test_chat_api.py` — zero-read grounded turn with seeded image doc (content==summary==description + question-overlapping chunk) → `done.sources` carries the image ref with `image_url`, `related` excludes it, `query_log.sources` unchanged; text-only KB turn → `sources == []` byte-identical.
+- Tests: `uv run pytest --no-cov` → **2842 passed**; task-specific subset → 8 passed.
+- Coverage: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (gate >90%; chat.py 99%, sole miss is the pre-existing SEC-14-04 503 fast path).
+- Lint/types: `uv run ruff check .` → all passed; `uv run pyright` → **0 errors, 0 warnings**.
+- Next pending task: `.agents/phases/todo/124_image_chips_and_answers/06_chat_ui_answer_image.md`.
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a2.validate b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a2.validate
new file mode 100644
index 0000000..befe810
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__05_auto_cite_image_docs.a2.validate
@@ -0,0 +1,109 @@
+........................................................................ [ 2%]
+........................................................................ [ 5%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 15%]
+........................................................................ [ 17%]
+........................................................................ [ 20%]
+........................................................................ [ 22%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 30%]
+........................................................................ [ 32%]
+........................................................................ [ 35%]
+........................................................................ [ 38%]
+........................................................................ [ 40%]
+........................................................................ [ 43%]
+........................................................................ [ 45%]
+........................................................................ [ 48%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 58%]
+........................................................................ [ 60%]
+........................................................................ [ 63%]
+........................................................................ [ 65%]
+........................................................................ [ 68%]
+........................................................................ [ 70%]
+........................................................................ [ 73%]
+........................................................................ [ 76%]
+........................................................................ [ 78%]
+........................................................................ [ 81%]
+........................................................................ [ 83%]
+........................................................................ [ 86%]
+........................................................................ [ 88%]
+........................................................................ [ 91%]
+........................................................................ [ 93%]
+........................................................................ [ 96%]
+........................................................................ [ 98%]
+.................................. [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 262 1 99%
+app/api/chat_images.py 50 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 179 1 99%
+app/api/git_sources.py 241 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 139 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 256 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 68 0 100%
+app/models.py 131 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 374 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 123 0 100%
+app/rag/git_sources.py 38 0 100%
+app/rag/importer.py 316 16 95%
+app/rag/llm.py 244 1 99%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 106 0 100%
+app/rag/retriever.py 248 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 48 0 100%
+app/schemas.py 400 0 100%
+--------------------------------------------------
+TOTAL 4669 29 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a1.err b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a1.md b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a1.md
new file mode 100644
index 0000000..6367e1a
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a1.md
@@ -0,0 +1,22 @@
+All green. Final verification summary:
+
+- **Unit pins added** (`tests/unit/test_image_chips_and_answers.py`, task-06 section): `appendAnswerImage` contract (lazy img, bytes route, title→summary alt/caption swap via `fetchContentSummary`, collapse-on-error `fig.remove()`, chip navigation), the three wiring points (done handler renders before chip row, `answer_image: ev.answer_image || undefined` in the save meta, restore renders before chip row), the shared page's non-interactive `` copy (zero controls, no guest fetch of the user-gated content endpoint, wired before `addSources`), and the answer-scale themed CSS (320px cap, contain, `var(--surface)`/`var(--line)`/`var(--ink-soft)` tokens only, zero hex literals, hover scoped to `a.answer-image`).
+
+**Implemented (task 06 — the answer figure UI):**
+- `frontend/assets/app.js`: new `appendAnswerImage(wrap, ref)` helper (lazy `
` on the public bytes route, visible caption, alt/caption = title → summary swap, collapse-to-nothing on img error, `documentUrl` href + left-click same-page modal); wired into the `done` handler right before `appendSources` (A4: below text, above chips), the `rememberBrainTurn` meta (`answer_image: ev.answer_image || undefined`), and `renderStoredMessage`'s brain branch (before `appendSources`).
+- `frontend/assets/shared.js`: `addAnswerImage` — a plain non-interactive `` (owner-locked zero controls; public bytes route makes the guest view faithful; caption keeps the title — the summary endpoint is user-gated for guests), wired in `renderSharedMessage` before `addSources`.
+- `frontend/assets/styles.css`: `.answer-image` family — `.source-image` language at answer scale: full-column width, 320px max-height cap, `object-fit: contain`, theme bordered-image treatment, AA-safe muted caption (3-line clamp; full text in alt/title), hover underline scoped to the chat page's anchor only.
+
+**Test / lint / coverage results:**
+- `uv run pytest` → **2848 passed** (unit + integration, exit 0)
+- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate)
+- `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors**
+- JS syntax: `node --check` clean on `app.js` + `shared.js` (ES module)
+
+**Notable decisions:**
+- Chat-page figure is an `` (the exact `appendSourceImageFigure` anchor pattern — the codebase calls that a "figure" too); shared page uses a plain `` (zero controls) — the `a.` CSS prefix keeps the hover out of the guest view without extra scoping.
+- Shared page renders the title as the always-visible caption and does **not** fetch `/api/documents/content` (it is `require_user`-gated; a guest fetch would 401 — the WCAG alt contract holds with the title standing in).
+- Caption line-clamped at 3 lines (summaries can be 12k chars; full text carried by `alt` + caption `title`).
+- No live infrastructure touched; no `app/` code changes (server-side work was tasks 01–05, already in the working tree).
+
+**Next pending task:** `07_answer_image_tests.md` — the mock-LLM `show_image` trigger + isolated E2E `test_answer_images.py` (it exercises this UI end-to-end: live figure, restore, shared page, collapse, toggle-off contract).
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a1.validate b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a1.validate
new file mode 100644
index 0000000..e999c31
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a1.validate
@@ -0,0 +1,109 @@
+........................................................................ [ 2%]
+........................................................................ [ 5%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 15%]
+........................................................................ [ 17%]
+........................................................................ [ 20%]
+........................................................................ [ 22%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 30%]
+........................................................................ [ 32%]
+........................................................................ [ 35%]
+........................................................................ [ 37%]
+........................................................................ [ 40%]
+........................................................................ [ 42%]
+........................................................................ [ 45%]
+........................................................................ [ 48%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 58%]
+........................................................................ [ 60%]
+........................................................................ [ 63%]
+........................................................................ [ 65%]
+........................................................................ [ 68%]
+........................................................................ [ 70%]
+........................................................................ [ 73%]
+........................................................................ [ 75%]
+........................................................................ [ 78%]
+........................................................................ [ 80%]
+........................................................................ [ 83%]
+........................................................................ [ 85%]
+........................................................................ [ 88%]
+........................................................................ [ 91%]
+........................................................................ [ 93%]
+........................................................................ [ 96%]
+........................................................................ [ 98%]
+........................................ [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 262 1 99%
+app/api/chat_images.py 50 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 179 1 99%
+app/api/git_sources.py 241 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 139 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 256 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 68 0 100%
+app/models.py 131 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 374 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 123 0 100%
+app/rag/git_sources.py 38 0 100%
+app/rag/importer.py 316 16 95%
+app/rag/llm.py 244 1 99%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 106 0 100%
+app/rag/retriever.py 248 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 48 0 100%
+app/schemas.py 400 0 100%
+--------------------------------------------------
+TOTAL 4669 29 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a2.err b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a2.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a2.md b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a2.md
new file mode 100644
index 0000000..d15a788
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a2.md
@@ -0,0 +1,18 @@
+All gates green. The prior rejection was a harness interruption (child run killed before reporting — empty `a2.err`, no `a2.md`); the work was already complete and validated (`a1.validate` = "validation OK").
+
+**Implemented (task 06 — the answer figure UI):**
+- `app.js`: `appendAnswerImage(wrap, ref)` (lazy `
` on public bytes route, visible caption, title→summary swap, collapse-to-nothing on img error, chip navigation); wired into the `done` handler (before `appendSources`, A4: below text, above chips), `rememberBrainTurn` meta (`answer_image: ev.answer_image || undefined`), and `renderStoredMessage` restore (before `appendSources`).
+- `shared.js`: `addAnswerImage` — plain non-interactive `` (zero controls; public bytes route), wired in `renderSharedMessage` before `addSources`.
+- `styles.css`: `.answer-image` family — answer scale (320px cap, contain, theme bordered-image, AA muted caption, hover scoped to `a.` only).
+
+**Test / lint / coverage:**
+- `uv run pytest --cov=app --cov-report=term-missing` → **2848 passed**, **TOTAL 99%** (>90% gate)
+- `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors**
+- JS syntax: `node --check` clean on `app.js` + `shared.js`
+
+**Notable decisions:**
+- Chat figure is `` (chip-anchor pattern); shared page uses plain `` (zero controls) — `a.` CSS prefix keeps hover out of guest view.
+- Shared page renders title caption, no `/api/documents/content` fetch (user-gated → would 401; alt contract holds with title).
+- No `app/` code changes (server work was tasks 01–05); no live infra touched.
+
+**Next pending task:** `07_answer_image_tests.md` — mock-LLM `show_image` trigger + isolated E2E `test_answer_images.py`.
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a2.validate b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a2.validate
new file mode 100644
index 0000000..e999c31
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__06_chat_ui_answer_image.a2.validate
@@ -0,0 +1,109 @@
+........................................................................ [ 2%]
+........................................................................ [ 5%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 15%]
+........................................................................ [ 17%]
+........................................................................ [ 20%]
+........................................................................ [ 22%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 30%]
+........................................................................ [ 32%]
+........................................................................ [ 35%]
+........................................................................ [ 37%]
+........................................................................ [ 40%]
+........................................................................ [ 42%]
+........................................................................ [ 45%]
+........................................................................ [ 48%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 58%]
+........................................................................ [ 60%]
+........................................................................ [ 63%]
+........................................................................ [ 65%]
+........................................................................ [ 68%]
+........................................................................ [ 70%]
+........................................................................ [ 73%]
+........................................................................ [ 75%]
+........................................................................ [ 78%]
+........................................................................ [ 80%]
+........................................................................ [ 83%]
+........................................................................ [ 85%]
+........................................................................ [ 88%]
+........................................................................ [ 91%]
+........................................................................ [ 93%]
+........................................................................ [ 96%]
+........................................................................ [ 98%]
+........................................ [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 262 1 99%
+app/api/chat_images.py 50 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 179 1 99%
+app/api/git_sources.py 241 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 139 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 256 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 68 0 100%
+app/models.py 131 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 374 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 123 0 100%
+app/rag/git_sources.py 38 0 100%
+app/rag/importer.py 316 16 95%
+app/rag/llm.py 244 1 99%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 106 0 100%
+app/rag/retriever.py 248 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 48 0 100%
+app/schemas.py 400 0 100%
+--------------------------------------------------
+TOTAL 4669 29 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a1.err b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a1.md b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a1.md
new file mode 100644
index 0000000..889c230
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a1.md
@@ -0,0 +1,13 @@
+All gates are green. Final report:
+
+**Task 07 complete — mock `show_image` trigger + isolated E2E suite**
+
+- `tests/e2e/mock_llm.py`: two new documented triggers — `SHOW_IMAGE_TRIGGER` ("show me the picture of", clause-gated: scripted `show_image` only, zero `read`s, follow-up keyed on the confirming line, final answer = sentence + `` tail echo) and `DIAGRAM_TRIGGER` ("which diagram is in your notes", zero tool calls, quotes the seeded image doc's block tail; works on toggle-off apps too). Image-doc pick imports `IMAGE_MIMES` (no drift).
+- `tests/e2e/test_answer_images.py` (new, 5 tests, ports 8162/8163): App A (feature ON) — feature flow (figure src/lazy/position A4, answer text, auto-cited chip + figure, no Nearby-docs repeat, wire pin: 4 tools offered, exactly one `show_image` executed, saved record carries `answer_image`), reload restore + shared-page render (fresh anonymous context), bug-fix-only zero-read chip flow; App B (default off) — 3 base tools only, no figure, no `answer_image` key in the record, bug-fix trigger still chips.
+- **Defect fixed from task 06's work:** the document image bytes route was `require_user` (phase 122/79 posture) while task 06's shared-page renderer assumed it public — guest shared chats 401'd and the figure collapsed, contradicting locked A4. Made the route public (phase-123 chat-image precedent: unguessable uuid4 = credential; document TEXT stays gated) in `app/api/docs.py` and rewrote the one auth pin in `tests/integration/test_docs_api.py`.
+
+**Gates:** `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` → 5 passed (twice — re-runnable; suite cleans its `saved_chats` rows); spot-checks `test_image_documents.py` 4 passed, `test_agent_document_tools.py` 4 passed (unmodified); `uv run pytest` → 2848 passed; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 99% (>90%); `uv run ruff check .` clean; `uv run pyright` → 0 errors.
+
+**Decisions:** App B pins `BOR_ANSWER_IMAGES=false` explicitly (process env beats an operator's `.env` — phase-122 default-app convention; unset is the same `False`). No live-infrastructure changes. No commits made (harness owns the phase commit).
+
+Next pending task: none in this phase — task 07 is the last of 124; the pipeline gate should move the phase dir to `complete/`.
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a1.validate b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a1.validate
new file mode 100644
index 0000000..e999c31
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a1.validate
@@ -0,0 +1,109 @@
+........................................................................ [ 2%]
+........................................................................ [ 5%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 15%]
+........................................................................ [ 17%]
+........................................................................ [ 20%]
+........................................................................ [ 22%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 30%]
+........................................................................ [ 32%]
+........................................................................ [ 35%]
+........................................................................ [ 37%]
+........................................................................ [ 40%]
+........................................................................ [ 42%]
+........................................................................ [ 45%]
+........................................................................ [ 48%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 58%]
+........................................................................ [ 60%]
+........................................................................ [ 63%]
+........................................................................ [ 65%]
+........................................................................ [ 68%]
+........................................................................ [ 70%]
+........................................................................ [ 73%]
+........................................................................ [ 75%]
+........................................................................ [ 78%]
+........................................................................ [ 80%]
+........................................................................ [ 83%]
+........................................................................ [ 85%]
+........................................................................ [ 88%]
+........................................................................ [ 91%]
+........................................................................ [ 93%]
+........................................................................ [ 96%]
+........................................................................ [ 98%]
+........................................ [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 262 1 99%
+app/api/chat_images.py 50 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 179 1 99%
+app/api/git_sources.py 241 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 139 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 256 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 68 0 100%
+app/models.py 131 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 374 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 123 0 100%
+app/rag/git_sources.py 38 0 100%
+app/rag/importer.py 316 16 95%
+app/rag/llm.py 244 1 99%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 106 0 100%
+app/rag/retriever.py 248 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 48 0 100%
+app/schemas.py 400 0 100%
+--------------------------------------------------
+TOTAL 4669 29 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a2.err b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a2.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a2.md b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a2.md
new file mode 100644
index 0000000..27bb9d0
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a2.md
@@ -0,0 +1,13 @@
+All gates green. Clean final report:
+
+**Task 07 complete — mock-LLM `show_image` trigger + isolated E2E suite**
+
+- `tests/e2e/mock_llm.py`: two new documented triggers — `SHOW_IMAGE_TRIGGER` ("show me the picture of", clause-gated on the `show_image` prompt clause: streams only a `show_image` tool call on the seeded image-extension `` block, zero `read`s; follow-up keyed on the "Image shown to the user" line streams the final answer + the `` tail echo) and `DIAGRAM_TRIGGER` ("which diagram is in your notes": zero tool calls, quotes the seeded image doc's block tail; fires on toggle-off apps too). Image-doc pick imports `IMAGE_MIMES` (drift-proof); both phrases verified collision-free.
+- `tests/e2e/test_answer_images.py` (new, 5 tests, ports 8162/8163): App A (feature ON) — feature flow (figure src/`loading="lazy"`/A4 position, answer text, auto-cited chip + figure, no Nearby-docs repeat, wire pin: 4 tools offered, exactly one `show_image` executed, saved record carries `answer_image`), reload restore + shared-page render in a fresh anonymous context, bug-fix-only zero-read chip flow; App B (default off) — offer list is exactly 3 base tools, no figure, no `answer_image` key in the record, bug-fix trigger still chips.
+- **Defect fixed (prior task 06 premise):** the image bytes route was `require_user` (phase 122/79) so guest shared chats 401'd and the figure collapsed, contradicting locked A4 — made the route public (phase-123 chat-image precedent: unguessable uuid4 = credential; document text stays gated) in `app/api/docs.py` and rewrote the single auth pin in `tests/integration/test_docs_api.py`.
+
+**Gates:** `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` → 5 passed (in isolation, re-run confirmed); `tests/e2e/test_image_documents.py` + `test_agent_document_tools.py` → 8 passed unmodified; `uv run pytest` → 2848 passed; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` → 0 errors.
+
+**Decisions:** App B pins `BOR_ANSWER_IMAGES=false` explicitly (process env beats an operator's `.env`; unset = the same `False`). Suite cleans its own `saved_chats` rows (shared-DB re-runnability). No live-infrastructure changes; no commits (harness owns the phase commit).
+
+**Next pending task:** none in this phase — task 07 is the last of 124; the pipeline gate should move the phase dir to `complete/`.
diff --git a/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a2.validate b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a2.validate
new file mode 100644
index 0000000..e999c31
--- /dev/null
+++ b/.agents/reports/124_image_chips_and_answers/124_image_chips_and_answers__07_answer_image_tests.a2.validate
@@ -0,0 +1,109 @@
+........................................................................ [ 2%]
+........................................................................ [ 5%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 15%]
+........................................................................ [ 17%]
+........................................................................ [ 20%]
+........................................................................ [ 22%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 30%]
+........................................................................ [ 32%]
+........................................................................ [ 35%]
+........................................................................ [ 37%]
+........................................................................ [ 40%]
+........................................................................ [ 42%]
+........................................................................ [ 45%]
+........................................................................ [ 48%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 58%]
+........................................................................ [ 60%]
+........................................................................ [ 63%]
+........................................................................ [ 65%]
+........................................................................ [ 68%]
+........................................................................ [ 70%]
+........................................................................ [ 73%]
+........................................................................ [ 75%]
+........................................................................ [ 78%]
+........................................................................ [ 80%]
+........................................................................ [ 83%]
+........................................................................ [ 85%]
+........................................................................ [ 88%]
+........................................................................ [ 91%]
+........................................................................ [ 93%]
+........................................................................ [ 96%]
+........................................................................ [ 98%]
+........................................ [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 262 1 99%
+app/api/chat_images.py 50 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 179 1 99%
+app/api/git_sources.py 241 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 139 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 256 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 68 0 100%
+app/models.py 131 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 374 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 123 0 100%
+app/rag/git_sources.py 38 0 100%
+app/rag/importer.py 316 16 95%
+app/rag/llm.py 244 1 99%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 106 0 100%
+app/rag/retriever.py 248 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 48 0 100%
+app/schemas.py 400 0 100%
+--------------------------------------------------
+TOTAL 4669 29 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.env.example b/.env.example
index fa55979..71f57cb 100644
--- a/.env.example
+++ b/.env.example
@@ -126,6 +126,12 @@ BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,
# BOR_IMAGE_DIR=~/bor-sources/images # persistent home for the served image bytes
# (uploads are replaced, checkouts re-cloned)
+# Phase 124 (answer images): off by default; enable alongside
+# BOR_IMAGES to let the model show a supporting image in its answer
+# (independent of it — BOR_IMAGES indexes image docs, this one shows
+# them in answers).
+# BOR_ANSWER_IMAGES=0
+
# --- Chat image questions (phase 123: attach an image to a question) ---
# Gated by the SAME BOR_IMAGES toggle above (enable only when the chat
# model supports vision). The question image is stored on the server —
diff --git a/app/api/chat.py b/app/api/chat.py
index 14a3ff1..c0f67df 100644
--- a/app/api/chat.py
+++ b/app/api/chat.py
@@ -462,7 +462,14 @@ def plan_turn(
fts_hits,
False,
build_high_prompt(
- suggested, notes=steering, kb_overview=kb_text, folder_lines=folder_lines
+ suggested,
+ notes=steering,
+ kb_overview=kb_text,
+ folder_lines=folder_lines,
+ # Phase 124 (task 03, LOCKED A5): the grounded HIGH prompt
+ # carries the show_image clause exactly when the
+ # BOR_ANSWER_IMAGES toggle is on (off = byte-identical).
+ show_image=settings.answer_images,
),
suggested,
related_docs,
@@ -486,6 +493,51 @@ def plan_turn(
)
+def merge_cited_docs(
+ read_docs: list[Document],
+ suggested_docs: list[Document],
+ deflected: bool,
+) -> list[Document]:
+ """Compose the citation tier (the done frame's ``sources``, PLAN §9).
+
+ Phase 119 (LOCKED A1): on grounded turns the agent's READ DOCS —
+ deduped by ``(source, path)``, read order; on deflected turns
+ nothing (a weak-hit answer is not a citation). Phase 124 (TODO L3)
+ adds the rule'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) — a ``read`` would
+ add only the marker line, so a real model never performs one and
+ the doc's chip never appeared. "Suggested" is therefore equivalent
+ to "read" for it, and it is auto-cited — in SUGGESTION order AFTER
+ the read docs, deduped against them (an image doc the agent also
+ explicitly read appears once, at its read position). Non-image
+ suggested docs are never auto-cited: the phase-119 read-docs-only
+ rule stays intact for text docs (a zero-read text turn still chips
+ nothing).
+ """
+ if deflected:
+ return []
+ cited: list[Document] = []
+ seen: set[tuple[str, str]] = set()
+ for doc in read_docs:
+ key = (doc.source, doc.path)
+ if key not in seen:
+ seen.add(key)
+ cited.append(doc)
+ # Phase 124 (TODO L3): the suggested-tier IMAGE docs join the
+ # citation tier — "suggested" is equivalent to "read" for them
+ # (their seed block is their entire content), so a zero-read
+ # grounded turn chips them too. Suggestion order, deduped.
+ for doc in suggested_docs:
+ if not doc.is_image:
+ continue
+ key = (doc.source, doc.path)
+ if key not in seen:
+ seen.add(key)
+ cited.append(doc)
+ return cited
+
+
@router.post("/chat")
async def chat(
request: ChatRequest,
@@ -1035,15 +1087,21 @@ async def chat(
# phase-119 cite discipline, A5) — and [] on
# deflected ones (a deflected answer cites nothing;
# the weak hits stay in the durable record).
+ # Phase 124 (TODO L3) gives that rule its single
+ # documented exception — the suggested-tier IMAGE
+ # docs (``merge_cited_docs``, below): a seed block
+ # IS an image doc's entire content, so "suggested"
+ # is equivalent to "read" for it and it is
+ # auto-cited in suggestion order AFTER the read
+ # docs, deduped (a read image doc stays at its read
+ # position, once). Text docs keep the read-docs-only
+ # rule — a zero-read text turn still chips nothing.
# A cancelled turn (the generator closed by the
# consumer) never reaches this step — no query_log row.
- cited_docs: list[Document] = []
- cited_seen: set[tuple[str, str]] = set()
- for doc in holder.read_docs:
- key = (doc.source, doc.path)
- if key not in cited_seen:
- cited_seen.add(key)
- cited_docs.append(doc)
+ cited_docs = merge_cited_docs(
+ holder.read_docs, plan.suggested_docs, plan.deflected
+ )
+ cited_seen = {(d.source, d.path) for d in cited_docs}
record_docs: list[Document] = []
seen: set[tuple[str, str]] = set()
for doc in [*plan.suggested_docs, *plan.related_docs, *holder.read_docs]:
@@ -1124,12 +1182,28 @@ async def chat(
for d in plan.related_docs
if (d.source, d.path) not in cited_seen
]
+ # Phase 124 (task 04): the answer's first-class image —
+ # the shown document's ref (the LLM's ``show_image``
+ # pick, task 02's ``holder.shown_images``), built by the
+ # SAME shared ref builder (source/path/title +
+ # ``image_url``). ``None`` on every other turn — a
+ # deflected turn runs no agent (``shown_images`` is
+ # always empty) and an image-less grounded turn never
+ # called the tool — and ``None`` is DROPPED on
+ # serialization, so those frames stay byte-identical to
+ # pre-phase (no key, no null).
+ answer_ref = (
+ source_ref_with_image(holder.shown_images[0])
+ if holder.shown_images
+ else None
+ )
yield sse_event(
ChatDoneEvent(
deflected=plan.deflected,
sources=cited_refs,
related=related_refs,
suggestions=plan.suggestions,
+ answer_image=answer_ref,
).model_dump()
)
finally:
diff --git a/app/api/docs.py b/app/api/docs.py
index 6979aa7..a32efe0 100644
--- a/app/api/docs.py
+++ b/app/api/docs.py
@@ -28,12 +28,17 @@ BYTES route: the persistent copy behind an ``is_image`` document's
``image_path``, served with the extension's ``Content-Type`` (the
``app.rag.summarizer.IMAGE_MIMES`` map — one map, one truth) and a
``Cache-Control: private, max-age=3600`` header (the bytes are
-content-hashed — long enough, bustable by re-upload). User-gated like
-the content endpoint (phase 79 — the ONLY anonymous surface is the
-shared chats): a missing doc, a non-image doc, a doc whose
+content-hashed — long enough, bustable by re-upload). PUBLIC (phase
+124 — the phase-123 chat-image precedent: the image is part of a
+shared chat's rendered content, and the shared chat — the app's ONLY
+anonymous surface — must render the answer figure faithfully for
+guests; the unguessable ``uuid4`` document id IS the credential, no
+enumeration value): a missing doc, a non-image doc, a doc whose
``image_path`` is NULL, or a row whose copy was lost all map to 404
``document not found`` (the router's unknown-document shape —
-traversal/UUID-guessing has no filesystem surface to hit).
+traversal/UUID-guessing has no filesystem surface to hit). The
+document's TEXT (the content endpoint) stays user-gated — only the
+bytes are public.
PATCH /api/folders/summary — the admin folder-description editor
(phase 97, task 03): update / create / clear a stored
@@ -212,16 +217,23 @@ def get_document_content(
def get_document_image(
doc_id: str,
db: Session = Depends(get_db), # noqa: B008
- _user: None = Depends(require_user), # noqa: B008 # phase 79 posture (see docstring)
) -> FileResponse:
"""The phase-122 (task 04) image BYTES route — the persistent copy
behind an ``is_image`` document's ``image_path``.
- Same auth posture as the document content endpoint (phase 79 —
- ``require_user``: admin OR live token holder; the ONLY anonymous
- surface is the shared chats — the image is part of a document's
- content, so it travels under the same gate): anonymous callers get
- 401 ``authentication required`` before any row is read.
+ PUBLIC (phase 124, LOCKED A4 — "visible … on the shared page"): a
+ guest opening a shared chat sees the brain answer's image figure,
+ so the bytes must serve ANONYMOUSLY. The trust model is the
+ phase-123 chat-image precedent (the question attachment travels in
+ the SAME shared content): the document id is an unguessable
+ server-minted ``uuid4`` — IT IS the credential, with no
+ enumeration value — exactly like the saved-chat id (phase 55) and
+ the chat-image filename (phase 123). (The phase-122/79
+ ``require_user`` posture was set before the image became part of a
+ shared chat's rendered content; the shared page's figure — built
+ on this route per the phase-124 design — 401'd and collapsed for
+ every guest, contradicting the owner-locked A4. The document's
+ TEXT stays user-gated: only the bytes are public.)
404 ``document not found`` (the router's unknown-document shape) in
every non-servable case — a missing id (an unparseable string maps
diff --git a/app/config.py b/app/config.py
index 9f33f9f..78cbc08 100644
--- a/app/config.py
+++ b/app/config.py
@@ -412,6 +412,23 @@ class Settings(BaseSettings):
#: (the ``upload_max_mb`` pattern).
chat_image_max_mb: int = 10
+ # --- Answer images (phase 124: the LLM shows a supporting image in the answer) ---
+ #: Feature toggle for the answer-image capability (phase 124,
+ #: ``BOR_ANSWER_IMAGES``; ``0``/``false`` = off — the DEFAULT,
+ #: LOCKED A2). The single knob that turns the whole feature on or
+ #: off. Gates the ANSWER side ONLY: while on, the grounded agent is
+ #: offered the ``show_image`` tool, the HIGH prompt carries its
+ #: teaching clause, and a shown doc rides the done frame's
+ #: ``answer_image`` key (and the saved record's). While off,
+ #: behavior is byte-identical everywhere — no ``show_image`` tool
+ #: offered, no prompt clause, no ``answer_image`` key in any frame
+ #: or record. INDEPENDENT of ``images``/``BOR_IMAGES`` (LOCKED A2):
+ #: that toggle indexes image documents, this one decides whether
+ #: the model may show one in an answer — ``on`` with ``images`` off
+ #: is a coherent (if image-less) state: the tool is offered, finds
+ #: no image docs, and always refuses.
+ answer_images: bool = False
+
# --- Docs push (phase 59: save a chat answer as documentation) ---
#: The git repo a saved chat answer is committed to (phase 59, D3):
#: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a
diff --git a/app/rag/agent.py b/app/rag/agent.py
index c6b9376..9a9e967 100644
--- a/app/rag/agent.py
+++ b/app/rag/agent.py
@@ -390,6 +390,71 @@ AGENT_TOOLS: list[dict[str, Any]] = [
},
]
+#: The 4th agent tool (phase 124, task 02; LOCKED A3/A5) — offered
+#: ONLY when ``settings.answer_images`` (``BOR_ANSWER_IMAGES``) is true:
+#: :func:`agent_tools` composes the offer list (the three base tools
+#: stay :data:`AGENT_TOOLS`, byte-identical). The description IS the
+#: relevance gate (A5 — "we don't want to just show every image that
+#: seems relevant"): call it only when the image itself contains the
+#: answer, at most ONE image per answer, the text answer is always
+#: given in full (the image supplements, never replaces). 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``).
+SHOW_IMAGE_TOOL: dict[str, Any] = {
+ "type": "function",
+ "function": {
+ "name": "show_image",
+ "description": (
+ "Show a standalone image document to the user as part of "
+ "your answer. Call it only when the image itself contains "
+ "the answer — a diagram, screenshot, or photo of the thing "
+ "the user asked about — never for a merely related image. "
+ "Show at most one image per answer. The image supplements "
+ "your text answer — answer fully in text either way. Its "
+ "`path` is the combined `source/path` string, exactly as "
+ "shown in the `ls` output or the `` blocks — a "
+ "bare document path will not resolve. Call one tool at a "
+ "time — wait for this result before your next call."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": (
+ "The image document to show, as the combined "
+ "`source/path` string exactly as shown in the "
+ "`ls` output (e.g. "
+ "'homelab/docs/architecture.png'). A bare "
+ "document path (without the source name) will "
+ "not resolve. Show at most one image per "
+ "answer — a second call is refused."
+ ),
+ }
+ },
+ "required": ["path"],
+ },
+ },
+}
+
+
+def agent_tools(settings: Settings) -> list[dict[str, Any]]:
+ """The grounded turn's tool offer list (phase 124, task 02).
+
+ The three base tools (:data:`AGENT_TOOLS`, byte-identical) plus
+ :data:`SHOW_IMAGE_TOOL` when ``settings.answer_images``
+ (``BOR_ANSWER_IMAGES``) is true — otherwise exactly the three base
+ tools (the pre-phase-124 offer, byte-identical: no 4th tool in the
+ wire ``tools`` array). A FRESH list on every call — callers never
+ mutate the result or the constants (``run_agent`` passes it straight
+ to the LLM request).
+ """
+ tools: list[dict[str, Any]] = list(AGENT_TOOLS)
+ if settings.answer_images:
+ tools.append(SHOW_IMAGE_TOOL)
+ return tools
+
#: Tool refusal texts (phase 37): rejected calls count in nothing
#: (``holder.tool_calls`` tracks executed calls); the round cap bounds
#: their pathological repetition (phase 45). The in-context line is a
@@ -407,6 +472,24 @@ ALREADY_IN_CONTEXT = (
UNKNOWN_TOOL = "Unknown tool."
MISSING_READ_ARGS = "read requires a string argument 'path'."
MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'."
+MISSING_SHOW_ARGS = "show_image requires a string argument 'path'."
+
+#: The ``show_image`` refusals (phase 124, task 02): the same contract
+#: as every other refusal — counts in nothing, consumes a round,
+#: changes no context. ``NOT_AN_IMAGE_DOC``: one ``{arg}`` field, the
+#: argument echoed as passed — a text doc is refused, never silently
+#: converted to a read (LOCKED A5). ``ALREADY_SHOWING``: the LOCKED A3
+#: one-image-per-answer gate, enforced SERVER-side (the model is taught
+#: it in the tool description + the prompt clause; the handler is the
+#: actual gate).
+NOT_AN_IMAGE_DOC = (
+ "'{arg}' is not an image document — only standalone image "
+ "documents can be shown as answers."
+)
+ALREADY_SHOWING = (
+ "Already showing an image this answer — do not show another one. "
+ "Answer from what you have."
+)
#: The read-truncation notice (phase 95, task 01) — appended, after the
#: shared :data:`TRUNCATION_MARKER`, to a ``read`` result whose document
@@ -1166,12 +1249,19 @@ class AgentHolder:
as today — this list only carries the truncation signal the agent
loop turns into :class:`app.rag.llm.ToolResultPiece` values (task 02
surfaces them to the UI).
+ ``shown_images`` (phase 124, task 02): the documents ``show_image``
+ showed, in call order — at most one in practice (LOCKED A3: a
+ second call is refused with :data:`ALREADY_SHOWING`, which appends
+ nothing). Read by ``app.api.chat`` after the stream for the done
+ frame's ``answer_image`` field (task 04) — the ``read_docs``
+ precedent (an API-layer read-after-stream signal).
"""
read_docs: list[Document] = field(default_factory=list)
tool_calls: int = 0
scaffold_stripped: int = 0
read_truncations: list[tuple[str, int, int]] = field(default_factory=list)
+ shown_images: list[Document] = field(default_factory=list)
def _execute_tool(
@@ -1327,6 +1417,51 @@ def _execute_tool(
f"{marker}"
f"{doc.content}"
)
+ if call.name == "show_image":
+ # Phase 124 (task 02; LOCKED A3/A5): the 4th agent tool — the
+ # model shows a standalone image document as part of its
+ # answer. Offered only when ``settings.answer_images``
+ # (``agent_tools``); the handler enforces the contract
+ # server-side — image docs only, ONE image per answer. Every
+ # refusal below counts in nothing and consumes a round, like
+ # every other refusal.
+ raw_path = call.arguments.get("path")
+ arg = raw_path.strip() if isinstance(raw_path, str) else ""
+ if not arg:
+ return MISSING_SHOW_ARGS
+ doc, _source, _path = _resolve_path(db, arg)
+ if doc is None:
+ # The same phase-72 "did you mean …?" teaching as the read
+ # branch (a refusal — not counted, no context).
+ return _no_document_refusal(db, arg)
+ if not doc.is_image:
+ # LOCKED A5: a text doc is refused, never silently
+ # converted to a read — the tool shows IMAGE documents
+ # only.
+ return NOT_AN_IMAGE_DOC.format(arg=arg)
+ if holder.shown_images:
+ # LOCKED A3: one image per answer — server-enforced, not
+ # just taught.
+ return ALREADY_SHOWING
+ holder.shown_images.append(doc)
+ holder.tool_calls += 1
+ # The confirming result: a NEW first line (absent from ``read``
+ # — the model must know the show registered) followed by the
+ # exact document block ``read`` returns for an image doc at-or-
+ # under-cap (the ``Document …`` header + phase-106 date line +
+ # :data:`IMAGE_DOC_MARKER` — the header contract the E2E mock
+ # pins on ``read`` results is deliberately reused here). An
+ # image doc's content is the summary-capped vision description
+ # — short by construction (phase 122, task 03), so NO
+ # truncation machinery; the plain block stays correct even if
+ # it ever exceeded the cap.
+ return (
+ "Image shown to the user — it is now part of your answer.\n"
+ f"Document {doc.source}/{doc.path}:\n"
+ f"date: {doc.created_at:%Y-%m-%d}\n"
+ f"{IMAGE_DOC_MARKER}\n"
+ f"{doc.content}"
+ )
if call.name == "grep":
raw_pattern = call.arguments.get("pattern")
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
@@ -1485,9 +1620,15 @@ async def run_agent(
# Phase 45: no per-tool budgets — the tools stay offered for the
# whole turn, bounded by the round cap. ``0`` is the no-tools kill
# switch: exactly one request with ``tools=None`` (the pre-phase-37
- # path).
+ # path). Phase 124 (task 02): the offer list is :func:`agent_tools`
+ # — the three base tools (byte-identical) plus ``show_image`` only
+ # when ``settings.answer_images`` (``BOR_ANSWER_IMAGES``) is on;
+ # off (the default) the offer is element-for-element the
+ # pre-phase-124 list.
max_rounds = settings.agent_max_rounds
- tools: list[dict[str, Any]] | None = AGENT_TOOLS if max_rounds > 0 else None
+ tools: list[dict[str, Any]] | None = (
+ agent_tools(settings) if max_rounds > 0 else None
+ )
rounds = 0
while True:
calls: list[ToolCallPiece] = []
diff --git a/app/rag/prompts.py b/app/rag/prompts.py
index 23e1bda..c16a51c 100644
--- a/app/rag/prompts.py
+++ b/app/rag/prompts.py
@@ -258,6 +258,43 @@ TOOLS_SECTION: str = (
""
)
+#: The ``show_image`` contract clause (phase 124, task 03, LOCKED A5) —
+#: appended INSIDE the ```` section of the HIGH prompt, after the
+#: existing three-tool copy and before the closing tag, ONLY when
+#: :func:`build_high_prompt` is called with ``show_image=True`` (the
+#: ``BOR_ANSWER_IMAGES`` toggle, via ``app.api.chat``). It teaches the
+#: same relevance gate as the ``show_image`` tool description in
+#: :mod:`app.rag.agent` (LOCKED A5: the gate is the LLM's decision — no
+#: server-side image scoring), plus the image-doc nature the tool
+#: description cannot fully carry: the text of an image document (in a
+#: ```` block or a ``read`` result) is a description
+#: GENERATED from the image, not the image's own words. The four gate
+#: elements, all stated here: (1) the image-doc nature; (2) the
+#: "contains the answer" bar — call it only when THAT image is what the
+#: user actually wants to see, never a merely related image, never to
+#: pad an answer; (3) at most ONE image per answer (also enforced
+#: server-side, task 02's ``ALREADY_SHOWING`` refusal); (4) the text
+#: answer is still provided in full — the image supplements, never
+#: replaces. ``TOOLS_SECTION`` itself stays BYTE-IDENTICAL (the
+#: pre-phase pins in :mod:`tests.unit.test_prompt_lock` hold); a
+#: ``show_image=False`` build (the default — every pre-phase call site)
+#: is byte-identical to the pre-task output. The ```` marker the
+#: E2E mock keys on is untouched either way — the clause rides on its
+#: presence, not the wording. The LOW/deflection prompt never carries
+#: the clause (a deflected turn has no tools and never shows an image —
+#: the phase's locked design).
+SHOW_IMAGE_CLAUSE: str = (
+ "`show_image` displays a standalone image document as part of your "
+ "answer. The text of an image document — in a block or "
+ "a `read` result — is a description GENERATED from the image, not "
+ "the image's own words. Call `show_image` only when that image is "
+ "what the user actually wants to see: a diagram, screenshot, or "
+ "photo of the very thing you are answering about — never for a "
+ "merely related image, and never to pad an answer. Show at most one "
+ "image per answer. The image supplements your text — answer fully "
+ "in text either way."
+)
+
def history_to_messages(
history: Sequence[HistoryTurn],
@@ -426,6 +463,7 @@ def build_high_prompt(
notes: Sequence[str] | None = None,
kb_overview: str | None = None,
folder_lines: Sequence[str] = (),
+ show_image: bool = False,
) -> str:
"""Grounded turn: locked persona (+ steering, + KB overview) + the
top-ranked documents' SUMMARY blocks + the ```` instructions
@@ -434,7 +472,8 @@ def build_high_prompt(
phase 118, LOCKED A6 re-revising A7: the ```` section
seeds SUMMARIES, never full texts — full text enters the context
only through the capped ``read`` tool; phase 119, LOCKED A4: the
- suggested-folder context lines, below).
+ suggested-folder context lines, below; phase 124, task 03, LOCKED
+ A5: the optional :data:`SHOW_IMAGE_CLAUSE`, below).
Section order: ```` → ```` → ````
→ ```` → ````; empty steering/overview omit their
@@ -442,6 +481,23 @@ def build_high_prompt(
cap — not the prompt — decides whether the tools are actually
offered to the model, see :mod:`app.rag.agent`).
+ *show_image* (phase 124, task 03, LOCKED A5): when true (the
+ caller passes the ``BOR_ANSWER_IMAGES`` toggle —
+ ``app.api.chat``'s grounded plan is the only call site that does),
+ the :data:`SHOW_IMAGE_CLAUSE` rides INSIDE the ```` section
+ — appended after the existing three-tool copy and before the
+ closing ```` tag, so the ```` marker the E2E mock
+ keys on is untouched and the clause reaches the model together with
+ the tool teaching. The section order is otherwise UNCHANGED and
+ :data:`TOOLS_SECTION` itself is never edited: the built section is
+ ``TOOLS_SECTION``'s text minus its final ```` + the clause
+ + the closing tag. The default ``False`` keeps EVERY pre-existing
+ call byte-identical (the pre-task pins in
+ :mod:`tests.unit.test_prompts` / :mod:`tests.unit.test_prompt_lock`
+ hold untouched). The deflection prompt (:func:`build_deflect_prompt`)
+ never carries the clause — a deflected turn has no tools and never
+ shows an image.
+
When at least one block is present, the section leads with the
start-here :data:`SUGGEST_INTRO` line (before the first block — the
phase-15 ``_STEERING_INTRO`` / phase-31 ``_KB_INTRO`` precedent): the
@@ -506,7 +562,16 @@ def build_high_prompt(
for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or [])):
if part:
prompt += "\n" + part
- return prompt + "\n\n" + body + "\n\n" + TOOLS_SECTION
+ if show_image:
+ # Phase 124 (task 03, LOCKED A5): the clause rides inside
+ # — TOOLS_SECTION's text minus its final , the
+ # clause (on its own line, after the existing three-tool copy),
+ # then the closing tag. TOOLS_SECTION itself is unedited and the
+ # show_image=False path keeps the exact pre-task concatenation.
+ tools_section = TOOLS_SECTION[: -len("")] + SHOW_IMAGE_CLAUSE + "\n"
+ else:
+ tools_section = TOOLS_SECTION
+ return prompt + "\n\n" + body + "\n\n" + tools_section
def build_deflect_prompt(
diff --git a/app/schemas.py b/app/schemas.py
index b4b8db8..087d5cc 100644
--- a/app/schemas.py
+++ b/app/schemas.py
@@ -242,6 +242,21 @@ class ChatDoneEvent(BaseModel):
citation chip — while ``sources`` stays the citation surface. The
field is ADDITIVE: old clients ignore unknown fields (PLAN §4 house
contract) and old frames without it parse with the default ``[]``.
+
+ Phase 124 (task 04): ``answer_image`` — the brain answer's
+ FIRST-CLASS image: the standalone image document the LLM chose to
+ show as part of its answer (its ``show_image`` pick, behind
+ ``BOR_ANSWER_IMAGES``; LOCKED A5 — the relevance gate is the model's
+ decision). It is the shown document's :class:`SourceRef` built by
+ the shared :func:`app.rag.retriever.source_ref_with_image` (source /
+ path / title + the ``image_url`` bytes route), distinct from a
+ citation in ``sources`` — the document may be cited as well, but
+ this key IS the answer's image (the UI's answer figure, task 06).
+ ``None`` (every deflected turn — they run no agent — every
+ image-less turn, and the whole pre-phase) is DROPPED on
+ serialization — ABSENT, never ``null`` — so those frames stay
+ byte-identical to pre-phase (the byte-identical-off contract, task
+ 01; the :class:`SourceRef.image_url` omission precedent).
"""
type: str = "done"
@@ -249,6 +264,25 @@ class ChatDoneEvent(BaseModel):
sources: list[SourceRef]
related: list[SourceRef] = []
suggestions: list[str] = []
+ #: Phase 124 (task 04) — see the class docstring. A single
+ #: :class:`SourceRef` (no list — LOCKED A3: one image per answer);
+ #: the ref's own phase-83 field caps bind.
+ answer_image: SourceRef | None = None
+
+ @model_serializer(mode="wrap")
+ def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
+ """The phase-124 answer-image omission rule: ``answer_image``
+ ``None`` (every deflected / image-less / pre-phase turn)
+ serializes WITHOUT the key — ABSENT, never ``null`` — so those
+ done frames stay byte-identical to pre-phase (the
+ :class:`SourceRef.image_url` / phase-123 ``image`` omission
+ precedents). A shown ref keeps all its fields (its own
+ serializer drops a ``None`` ``image_url`` — a shown doc is an
+ image doc, so in practice it always carries it)."""
+ data = handler(self)
+ if self.answer_image is None:
+ data.pop("answer_image", None)
+ return data
class ChatErrorEvent(BaseModel):
@@ -967,22 +1001,47 @@ class ChatMessage(BaseModel):
# omission precedent). The path is ≤ 500 chars — no phase-83
# cap pressure (it is never a data URL, LOCKED A5).
image: str | None = Field(default=None, max_length=500)
+ # Phase 124 (task 04): the brain answer's FIRST-CLASS image — the
+ # shown document's :class:`SourceRef` (the LLM's ``show_image``
+ # pick, ``BOR_ANSWER_IMAGES``), on the BRAIN record only. It is a
+ # DIFFERENT thing from the user record's phase-123 ``image`` (the
+ # stored PATH STRING of the question's attachment): that field is
+ # untouched and the two are semantically distinct — the attachment
+ # belongs to the question, the answer image to the brain's reply
+ # (a user record never carries ``answer_image``, a brain record
+ # never carries ``image``). A single ref — no length cap needed
+ # (the ref's own phase-83 field caps bind). The done-time
+ # auto-save (``rememberBrainTurn``, task 06) persists the done
+ # frame's ``answer_image`` into this key, and the restore path
+ # re-renders the answer figure from it — the field must exist so
+ # the ``extra="forbid"`` boundary does NOT 422 those saves (the
+ # phase-113 lesson, applied up front: a missing key silently
+ # unsaved every grounded brain record). ``None`` (every pre-phase
+ # record, every image-less turn) serializes WITHOUT the key —
+ # ABSENT, never ``null`` (the :meth:`_drop_image_when_absent`
+ # serializer below), so pre-phase and image-less chats round-trip
+ # byte-identically (the phase-50 contract).
+ answer_image: SourceRef | None = None
@model_serializer(mode="wrap")
def _drop_image_when_absent(self, handler: SerializerFunctionWrapHandler) -> Any:
- """The phase-123 image omission rule: ``image: None`` (every
- text-only record, and every pre-phase-123 record) serializes
- WITHOUT the key — ABSENT, never ``null`` — so the stored
- ``bor.chat.v1`` JSONB and the saved/shared wire shape stay
- byte-identical to pre-phase for text-only chats (the phase-50
- round-trip contract). A record WITH an image keeps the path —
- the user bubble, the refreshed page, and the shared chat all
- render it from the served route (the image is part of the
- chat's content, so it rides the same public/credential-
- is-the-id trust model)."""
+ """The image omission rules: ``image: None`` (every text-only
+ record, and every pre-phase-123 record) and ``answer_image:``
+ ``None`` (every record without a shown answer image, and every
+ pre-phase-124 record) serialize WITHOUT the keys — ABSENT,
+ never ``null`` — so the stored ``bor.chat.v1`` JSONB and the
+ saved/shared wire shape stay byte-identical to pre-phase for
+ those records (the phase-50 round-trip contract; the
+ phase-122 ``SourceRef.image_url`` omission precedent). A
+ record WITH one keeps it — the user bubble, the refreshed page,
+ and the shared chat all render the image from the served route
+ (the image is part of the chat's content, so it rides the same
+ public/credential-is-the-id trust model)."""
data = handler(self)
if self.image is None:
data.pop("image", None)
+ if self.answer_image is None:
+ data.pop("answer_image", None)
return data
diff --git a/frontend/assets/app.js b/frontend/assets/app.js
index 47a98d5..1136c64 100644
--- a/frontend/assets/app.js
+++ b/frontend/assets/app.js
@@ -1726,6 +1726,81 @@ function fetchContentSummary(source, path) {
.then((doc) => (doc && typeof doc.summary === "string" ? doc.summary : null));
}
+/* Phase 124 (task 06, TODO L3): the LLM's ANSWER IMAGE — the brain
+ * answer's first-class figure (the `done` frame's `answer_image` ref,
+ * task 04). A4: it sits in the .msg-body BELOW the answer text and
+ * ABOVE the source-chip row — the callers append it right BEFORE
+ * appendSources creates the chip row, so it lands in between. The
+ * text bubble is untouched: the figure is ADDITIVE ("summaries
+ * should still be provided").
+ *
+ * The visual sibling of appendSourceImageFigure (the compact chip
+ * figure) but a different surface: the answer figure is the answer's
+ * own image, not a chip accessory — .answer-image scales it up (the
+ * .source-image language at answer size, styles.css). Same WCAG alt
+ * contract as the chip figure: alt + the VISIBLE caption = the
+ * document's SUMMARY (the vision description); the frame carries no
+ * summary (the ref is source/path/title/image_url), so the figure
+ * fetches the content endpoint the chip's modal already uses and
+ * swaps the summary in; until it settles — and when it fails — the
+ * title stands in (a caption is ALWAYS visible). A FAILED IMAGE LOAD
+ * REMOVES the whole figure (the collapse rule — the answer figure has
+ * no chip of its own to fall back to; the chip row below still cites
+ * the doc, the phase-124 auto-cite): an answer whose image bytes are
+ * gone renders as a normal answer, never a broken-image icon.
+ * Navigation reuses the chip affordance: the same documentUrl href
+ * (the /document.html escape hatch) + left-click → same-page modal
+ * (phase 26, the appendSources/appendSourceImageFigure pattern). */
+function appendAnswerImage(wrap, ref) {
+ if (
+ !ref ||
+ typeof ref.source !== "string" ||
+ typeof ref.path !== "string" ||
+ typeof ref.image_url !== "string" ||
+ !ref.image_url
+ )
+ return; // no ref (or a malformed one) → no figure DOM at all
+ const body = wrap.querySelector(".msg-body");
+ const label = `${ref.source}/${ref.path}`;
+ const fig = document.createElement("a");
+ fig.className = "answer-image";
+ fig.href = documentUrl(ref.source, ref.path, "/"); // back → the chat page
+ fig.title = label; // full path as the native tooltip (chip pattern)
+ fig.setAttribute("aria-label", label); // the accessible name is the full path
+ fig.addEventListener("click", (e) => {
+ e.preventDefault(); // no new tab (phase 26) — the modal takes over
+ e.stopPropagation();
+ openDocumentModal(ref.source, ref.path, fig);
+ });
+ const img = document.createElement("img");
+ img.className = "answer-image-img";
+ img.src = ref.image_url; // the public bytes route (task 04's ref)
+ img.loading = "lazy"; // A4: lazy-loaded
+ img.alt = ref.title || label; // the summary arrives via the fetch below
+ img.addEventListener("error", () => {
+ // Collapse to NOTHING (there is no chip inside the figure to fall
+ // back to) — the answer renders normally, the chip row below
+ // still cites the doc.
+ fig.remove();
+ });
+ const caption = document.createElement("span");
+ caption.className = "answer-image-caption";
+ caption.textContent = ref.title || label; // visible caption: the title first…
+ fig.append(img, caption);
+ body.appendChild(fig);
+ // …and the document's summary once the content fetch settles (the
+ // chip figure's SAME pattern — fetchContentSummary fails soft to
+ // null: the title stays).
+ fetchContentSummary(ref.source, ref.path).then((summary) => {
+ if (!fig.isConnected) return; // collapsed (img error) or bubble cleared
+ if (summary && summary.trim() !== "") {
+ img.alt = summary;
+ caption.textContent = summary;
+ caption.title = summary; // full text (the caption is line-clamped)
+ }
+ });
+}
+
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase 04,
shared component + one-tap submit, phase 05). The group is accessible
(role=list + aria-label) and wraps cleanly at every width. */
@@ -1906,6 +1981,11 @@ function renderStoredMessage(m) {
wrap.classList.add("is-deflected");
appendMaybeTry(wrap, m.suggestions);
}
+ // Phase 124 (task 06, TODO L3): the answer image restores with the
+ // bubble — the SAME helper + the SAME A4 position as the live done
+ // path (below the text, above the chip row). A record without the
+ // key renders byte-identically (pre-phase chats restore unchanged).
+ if (m.answer_image) appendAnswerImage(wrap, m.answer_image);
appendSources(wrap, m.sources);
// Phase 120: a failed turn (m.failed) is a note, not an answer — no
// Tune (the "note, not an answer" scope the m.stopped exclusion
@@ -2985,6 +3065,13 @@ async function runTurn(text, { reask = false, image = null } = {}) {
wrap.classList.add("is-deflected");
appendMaybeTry(wrap, ev.suggestions);
}
+ // Phase 124 (task 06, TODO L3): the LLM's answer image — the
+ // answer's first-class figure (A4): appended to .msg-body
+ // right BEFORE the chip row, so it lands below the answer
+ // text and above the sources. The text is untouched (the
+ // figure is additive — "summaries should still be provided");
+ // a frame without it renders byte-identically (no figure DOM).
+ if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);
appendSources(wrap, ev.sources);
// Phase 113 (task 02): the cited tier stays the citation
// surface (the chips above); the related tier (scored docs
@@ -3019,6 +3106,12 @@ async function runTurn(text, { reask = false, image = null } = {}) {
tools: toolAcc.length ? toolAcc : undefined,
deflected: !!ev.deflected,
sources: ev.sources,
+ // Phase 124 (task 06, TODO L3): the answer image persists
+ // with the turn (undefined drops the key from the JSON — the
+ // house optional-meta pattern, the `related` line below is
+ // the precedent): an image-less / pre-phase turn stores a
+ // byte-identical record.
+ answer_image: ev.answer_image || undefined,
// Phase 113 (task 02): the related tier persists with the
// turn (undefined drops the key from the JSON — the house
// optional-meta pattern), so the restore path re-renders the
diff --git a/frontend/assets/shared.js b/frontend/assets/shared.js
index c92eb3e..447bf9c 100644
--- a/frontend/assets/shared.js
+++ b/frontend/assets/shared.js
@@ -349,6 +349,50 @@ function addBubbleImage(wrap, src, alt) {
bubble.prepend(img);
}
+/* The brain answer's image (phase 124, task 06, TODO L3) — the local
+ * copy of the chat page's appendAnswerImage (the per-page duplication
+ * house style: this file keeps its own small copies of the chat
+ * page's message-fragment builders). NON-INTERACTIVE like every other
+ * shared-page fragment (owner-locked zero controls: no link, no
+ * modal, no click handler — a plain , unlike the chat page's
+ * anchor). The bytes route is PUBLIC (the token is the shared chat's
+ * credential, like the phase-123 user-image contract), so the img
+ * loads for guests exactly as it does for the owner. The visible
+ * caption keeps the document's TITLE: the content endpoint the chat
+ * page fetches for the summary is user-gated (a guest fetch would
+ * just 401), and a caption is ALWAYS visible either way (the WCAG
+ * alt contract holds with the title standing in). A failed load
+ * REMOVES the figure (the collapse rule — never a broken-image
+ * icon). */
+function addAnswerImage(wrap, ref) {
+ if (
+ !ref ||
+ typeof ref.source !== "string" ||
+ typeof ref.path !== "string" ||
+ typeof ref.image_url !== "string" ||
+ !ref.image_url
+ ) {
+ return; // no ref (or a malformed one) → no figure DOM at all
+ }
+ const body = wrap?.querySelector?.(".msg-body");
+ if (!body) return;
+ const title =
+ typeof ref.title === "string" && ref.title ? ref.title : "image";
+ const fig = document.createElement("figure");
+ fig.className = "answer-image";
+ const img = document.createElement("img");
+ img.className = "answer-image-img";
+ img.src = ref.image_url; // the public bytes route
+ img.loading = "lazy";
+ img.alt = title;
+ img.addEventListener("error", () => fig.remove()); // collapse: never a broken icon
+ const caption = document.createElement("span");
+ caption.className = "answer-image-caption";
+ caption.textContent = title; // the visible caption (always)
+ fig.append(img, caption);
+ body.appendChild(fig);
+}
+
/* One stored record through the SAME .msg structure the chat page
* uses (pixel-parity with the chat page's restore path): user → the
* .msg.user bubble (with the question's attached image when the
@@ -356,9 +400,11 @@ function addBubbleImage(wrap, src, alt) {
* .msg.brain bubble with the optional
* thinking block (restored COLLAPSED — phase 17), the tool lines,
* the deflection treatment + the plain-text "Maybe try" chips, the
- * plain-text source chips, and the stopped note. NO interactive
- * markup is ever created here — no buttons, no forms, no links, no
- * click handlers (owner-locked: zero controls). Markdown goes
+ * brain answer's image figure (phase 124, task 06 — a plain
+ * non-interactive ), the plain-text source chips, and the
+ * stopped note. NO interactive markup is ever created here — no
+ * buttons, no forms, no links, no click handlers (owner-locked:
+ * zero controls). Markdown goes
* through the global escape-first renderMarkdown (markdown.js): the
* stored payloads are raw text, so the renderer's XSS safety applies
* unchanged. */
@@ -385,6 +431,11 @@ function renderSharedMessage(m) {
wrap.classList.add("is-deflected");
addMaybeTry(wrap, m.suggestions);
}
+ // Phase 124 (task 06, TODO L3): the answer image renders with the
+ // brain bubble — the SAME A4 position as the chat page's render
+ // (below the text, above the chip row). A record without the key
+ // renders byte-identically (no figure DOM).
+ addAnswerImage(wrap, m.answer_image);
addSources(wrap, m.sources);
if (m.stopped) addStoppedNote(wrap);
}
diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css
index 37f0e34..9a2fa6a 100644
--- a/frontend/assets/styles.css
+++ b/frontend/assets/styles.css
@@ -814,6 +814,53 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
.source-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
+/* Phase 124 (task 06, TODO L3): the LLM's ANSWER IMAGE — the brain
+ answer's first-class figure (A4: below the text, above the chips).
+ The .source-image language at ANSWER scale — the figure is the
+ answer's own image, not a chip accessory: max-width 100% of the
+ .msg-body column, a 320px height cap (the 46rem chat column — a
+ full-height image would blow the turn's height), contain on the
+ theme's surface with the bordered-image treatment
+ (.source-image-img's border/background/radius), the visible
+ caption in the AA-safe muted ink (the same --ink-soft the chip
+ caption uses — the row's page background, 8.6:1). Token-only (the
+ zero-literal invariant — the monochrome themes carry it). The
+ hover underline applies to the chat page's ANCHOR figure only (the
+ shared page's figure is a plain — zero controls, the
+ element-type scope keeps it out). */
+.answer-image {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.2rem;
+ max-width: 100%;
+ min-width: 0;
+}
+.answer-image-img {
+ display: block;
+ max-width: 100%;
+ max-height: 320px; /* the one free number (task 06): the answer scale */
+ width: auto;
+ height: auto;
+ object-fit: contain;
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+}
+.answer-image-caption {
+ max-width: 100%;
+ color: var(--ink-soft); /* 8.6:1 on --bg (AA) — the row's page background */
+ font-size: 0.75rem;
+ line-height: 1.25;
+ text-align: center;
+ display: -webkit-box;
+ -webkit-line-clamp: 3; /* the summary can be long; the alt + title carry the full text */
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+a.answer-image { text-decoration: none; }
+a.answer-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
+
/* Phase 113 (task 02): the related-docs row — the SECONDARY tier of
scored docs (phase 113 task 01's usefulness bar demotes the
sub-floor hits out of the citation surface; on a deflected turn the
diff --git a/tests/conftest.py b/tests/conftest.py
index 1e09531..c3d48ed 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -78,6 +78,13 @@ os.environ["BOR_IMAGES"] = str(_Settings.model_fields["images"].default)
os.environ["BOR_IMAGE_EXTENSIONS"] = str(_Settings.model_fields["image_extensions"].default)
os.environ["BOR_IMAGE_DIR"] = str(_Settings.model_fields["image_dir"].default)
+# Phase 124 (task 04): the same leak class for the answer-image toggle
+# — an operator's local ``.env`` may legitimately carry
+# ``BOR_ANSWER_IMAGES`` (the dev environment runs the feature on), and
+# the byte-identical-off pins (the ``show_image`` offer absent, the
+# ``answer_image`` done-frame key absent) must see the code default.
+os.environ["BOR_ANSWER_IMAGES"] = str(_Settings.model_fields["answer_images"].default)
+
from app.db import SessionLocal, db_available # noqa: E402
from app.main import app as fastapi_app # noqa: E402
diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py
index c8c7bbb..740ceae 100644
--- a/tests/e2e/mock_llm.py
+++ b/tests/e2e/mock_llm.py
@@ -467,6 +467,56 @@ Implements just enough of the aipi surface:
the trigger phrase is disjoint from every other trigger's; verified
2026-09-16: no existing E2E question or fixture file contains the
phrase, so every other suite is unaffected.
+ - user message containing ``show me the picture of``
+ (``SHOW_IMAGE_TRIGGER``, phase 124 task 07 — the image-chips-and-
+ answers suite ``tests/e2e/test_answer_images.py``) **and** the
+ system prompt contains ``show_image`` (the tool clause's presence
+ — the ``BOR_ANSWER_IMAGES`` toggle's server-side marker: the
+ clause rides inside the HIGH prompt's ```` section ONLY
+ when the toggle is on, so on a default-OFF app the trigger NEVER
+ fires and the mock behaves exactly as pre-phase for every other
+ suite) -> the deterministic SHOW_IMAGE flow, discriminated
+ statelessly from the tool results (streaming only): the mock
+ picks the IMAGE document seeded in the ```` blocks —
+ the block whose ``path`` attribute ends in an image extension
+ (``app.rag.summarizer.IMAGE_MIMES``' family — the suite's
+ single-PNG fixture guarantees exactly one such block):
+ * request 1 (``tools`` offered, no confirmed show yet): stream
+ ONLY ``tool_calls`` deltas — ``show_image`` with
+ ``{"path": }`` (synthetic id
+ ``call_show``, ``finish_reason: "tool_calls"``, no content) —
+ NO ``read`` (the ZERO-READ turn: the image doc's seed block
+ already carries its entire content, so a real model never
+ reads it — the zero-read shape is what makes the bug-fix's
+ chip assertion meaningful);
+ * the follow-up request (a ``tool``-role result carrying the
+ agent's confirming line ``Image shown to the user`` —
+ ``_SHOW_CONFIRM_MARKER``) in the messages: the deterministic
+ FINAL answer — a short sentence ending with the ````
+ block's tail echo (the phase-24 END_OF_NOTES echo convention —
+ it proves the seed was there), NO further tool calls.
+ Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger
+ phrase — the phase-71/72/94/118 ordering convention); verified
+ 2026-09-26: no existing E2E question or fixture file contains the
+ phrase, so every other suite is unaffected.
+ - user message containing ``which diagram is in your notes``
+ (``DIAGRAM_TRIGGER``, phase 124 task 07 — the BUG-FIX-ONLY
+ trigger, same suite) -> the deterministic ZERO-READ answer: the
+ mock answers straight from the seed with NO tool calls at all
+ (no ``show_image``, no ``read`` — unlike the ``SHOW_IMAGE_
+ TRIGGER`` flow it needs NO clause, so it also fires on a default-
+ OFF app: the chips fix is not gated by ``BOR_ANSWER_IMAGES``,
+ it belongs to the ``BOR_IMAGES``-indexed docs) and QUOTES THE
+ TAIL of the seeded image doc's ```` block (the same
+ image-doc pick as above): proving the description WAS in the
+ prompt (for an image doc the seed block IS its entire content)
+ and that its chip appears with NO tool calls at all (the phase-
+ 124 auto-cite — the fix for the owner's report). Checked BEFORE
+ the ``DEFLECT_MODE`` branch (the marker lives in the user message
+ — the ``TABLE_TRIGGER``/``HISTORY_TRIGGER`` convention), so a
+ marker question always gets the echo whatever the honesty gate
+ says. Verified 2026-09-26: no existing E2E question or fixture
+ file contains the phrase, so every other suite is unaffected.
Failure injection (phase 67, LLM retry, TODO.md L3) — deterministic
dead-endpoint behavior for the retry E2E suite (``tests/e2e/
@@ -580,6 +630,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
from app.rag.agent import ALREADY_IN_CONTEXT, CORRECTION_INSTRUCTION
from app.rag.folder_summaries import FOLDER_HEADER_PREFIX # phase 94: the mock's key
+from app.rag.summarizer import IMAGE_MIMES # phase 124: the image-doc extension family
app = FastAPI()
@@ -1756,6 +1807,117 @@ _SUMMARY_SEED_READ_CALL_RE = re.compile(
)
+# ---------------------------------------------------------------------------
+# Phase 124 (task 07, image chips and answers, TODO L3): the scripted
+# SHOW_IMAGE flow + the zero-read bug-fix trigger — see the module
+# docstring
+# ---------------------------------------------------------------------------
+
+#: A user message containing this substring (case-insensitive) —
+#: combined with the ``show_image`` prompt clause in the system
+#: prompt (the ``BOR_ANSWER_IMAGES`` toggle's server-side marker —
+#: the clause rides inside the HIGH prompt's ```` section ONLY
+#: when the toggle is on) — drives the deterministic SHOW_IMAGE flow
+#: (the mock shows the seeded image document and answers straight
+#: from the seed — ZERO ``read`` calls). On a default-OFF app the
+#: clause is absent, so the trigger NEVER fires there and the mock
+#: behaves exactly as pre-phase for every other suite. Disjoint from
+#: every other trigger phrase — the phase-71/72/94/118 ordering
+#: convention; verified 2026-09-26: no existing E2E question or
+#: fixture file contains the phrase, so every other suite is
+#: unaffected.
+SHOW_IMAGE_TRIGGER = "show me the picture of"
+
+#: The phase-124 BUG-FIX-ONLY trigger (case-insensitive): the mock
+#: answers straight from the seed with ZERO tool calls and QUOTES THE
+#: TAIL of the seeded image doc's ```` block — proving the
+#: description was in the prompt (for an image doc the seed block IS
+#: its entire content) and that its chip appears with NO tool calls
+#: at all (the phase-124 auto-cite). Unlike ``SHOW_IMAGE_TRIGGER``
+#: it needs NO clause — the chips fix is not gated by
+#: ``BOR_ANSWER_IMAGES`` (it belongs to the ``BOR_IMAGES``-indexed
+#: docs), so it also fires on a default-OFF app. Checked BEFORE the
+#: ``DEFLECT_MODE`` branch in :func:`compose_answer` (the marker
+#: lives in the user message — the TABLE/HISTORY convention), so a
+#: marker question always gets the echo whatever the honesty gate
+#: says.
+DIAGRAM_TRIGGER = "which diagram is in your notes"
+
+#: The agent's ``show_image`` SUCCESS result's confirming first line
+#: (``app.rag.agent``'s ``show_image`` branch — the line ``read``'s
+#: result never carries): the SHOW_IMAGE flow's follow-up request
+#: keys on it in a ``tool``-role result (stateless discrimination —
+#: the house marker-flow convention).
+_SHOW_CONFIRM_MARKER = "Image shown to the user"
+
+#: The image-doc extension family (``app.rag.summarizer.IMAGE_MIMES``
+#: — phase 122's ext→mime map, imported so the mock can never drift
+#: from the app's image family): a seeded ```` block is
+#: the IMAGE document when its ``path`` attribute ends in one of
+#: these (the suite's single-PNG fixture guarantees exactly one such
+#: block — the pick is deterministic).
+_IMAGE_PATH_SUFFIXES = tuple(IMAGE_MIMES)
+
+
+def _seeded_image_doc(system: str) -> tuple[str, str, str] | None:
+ """The seeded image doc's ``(source, path, content)`` — or ``None``.
+
+ Phase 124 (task 07): the ```` block (the
+ :data:`_DOCUMENT_BLOCK_RE` machinery) whose ``path`` attribute
+ ends in an image extension (:data:`_IMAGE_PATH_SUFFIXES`) is the
+ seeded image document — for an image doc the block's content IS
+ the document's entire text (the vision description — content ==
+ summary, phase 122 task 03). Both of this phase's triggers pick
+ the doc this way (the suite's single-PNG fixture guarantees
+ exactly one such block; ``None`` = a text-only KB, the premise
+ broke — the caller degrades loudly).
+ """
+ for block in _DOCUMENT_BLOCK_RE.finditer(system):
+ if block.group("path").lower().endswith(_IMAGE_PATH_SUFFIXES):
+ return (block.group("source"), block.group("path"), block.group("content"))
+ return None
+
+
+def _show_image_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
+ """Classify a phase-124 SHOW_IMAGE request (see the module
+ docstring). Stateless over the messages, like the other marker
+ flows:
+
+ * ``("call", combined)`` — the system prompt carries the
+ ``show_image`` clause (the toggle-ON gate) and no ``tool``-role
+ result has confirmed the show yet: stream ONLY the
+ ``show_image`` tool-call delta on the seeded image doc's
+ combined ``source/path`` (synthetic id ``call_show``,
+ ``finish_reason: "tool_calls"``, no content) — NO ``read`` (the
+ zero-read turn).
+ * ``("answer",)`` — a ``tool``-role result carrying the agent's
+ confirming line (:data:`_SHOW_CONFIRM_MARKER`) is in the
+ messages: the deterministic FINAL answer — a short sentence
+ ending with the ```` block's tail echo (the phase-24
+ END_OF_NOTES echo convention — it proves the seed was there),
+ NO further tool calls.
+ * ``None`` — not the flow: the trigger phrase is absent, the
+ clause is absent (a default-OFF app — the mock behaves exactly
+ as pre-phase), the turn is deflected (no ```` seed
+ — a deflection prompt never carries one), or no seeded image
+ doc exists (a text-only KB — the premise broke; the suite fails
+ loudly on the missing figure).
+ """
+ if SHOW_IMAGE_TRIGGER not in _user(body).lower():
+ return None
+ system = _system(body)
+ if "show_image" not in system:
+ return None
+ doc = _seeded_image_doc(system)
+ if doc is None:
+ return None
+ if any(_SHOW_CONFIRM_MARKER in r for r in _tool_results(body)):
+ return ("answer",)
+ if not body.get("tools"):
+ return None
+ return ("call", f"{doc[0]}/{doc[1]}")
+
+
def _summary_seed_read_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-118 scripted summary-read request (see the
module docstring). The question carries the scripted call (``read
@@ -2280,6 +2442,31 @@ def compose_answer(body: dict[str, Any]) -> str:
# message, a deflection prompt never carries it, so a marker
# question always gets the echo whatever the gate says.
answer = _history_echo(body)
+ elif DIAGRAM_TRIGGER in user.lower():
+ # Phase 124 (task 07): the BUG-FIX-ONLY trigger — the mock
+ # answers straight from the seed with ZERO tool calls (no
+ # ``show_image``, no ``read`` — the clause is NOT required,
+ # so the flow also fires on a default-OFF app: the chips fix
+ # belongs to the ``BOR_IMAGES``-indexed docs) and quotes the
+ # TAIL of the seeded image doc's ```` block (the
+ # same pick as the SHOW_IMAGE flow — the block's content is
+ # the image doc's entire text, the vision description):
+ # proving the description WAS in the prompt and that the chip
+ # appears with NO tool calls at all (the phase-124 auto-cite
+ # — the fix for the owner's report). Checked BEFORE the
+ # DEFLECT_MODE branch (the marker lives in the user message —
+ # the TABLE/HISTORY convention), so a marker question always
+ # gets the echo whatever the honesty gate says. A text-only KB
+ # (no seeded image doc) degrades to the plain line — the
+ # suite's premise broke; it fails loudly on the missing quote.
+ image_doc = _seeded_image_doc(system)
+ answer = (
+ f"Your notes carry that diagram — it reads: “{image_doc[2][-160:]}” "
+ "(Deterministic mock answer for E2E.)"
+ if image_doc
+ else "Your notes don't carry that diagram — there is nothing to show. "
+ "(Deterministic mock answer for E2E.)"
+ )
elif "DEFLECT_MODE" in system:
answer = (
"Ah — I haven't done anything like that, so I don't want to make stuff up! "
@@ -2911,6 +3098,42 @@ def chat_completions(body: dict[str, Any]) -> Any:
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
+ # Phase 124 (task 07, image chips and answers, TODO L3): the
+ # scripted SHOW_IMAGE flow — the mock shows the seeded image
+ # document (the ```` block with the image-
+ # extension path) and answers straight from the seed: ZERO
+ # ``read`` calls (the zero-read turn that makes the bug-fix's
+ # chip assertion meaningful). Clause-gated — the ``show_image``
+ # teaching line in the system prompt is the
+ # ``BOR_ANSWER_IMAGES`` toggle's presence marker, so on a
+ # default-OFF app the trigger never fires and the mock stays
+ # byte-identical to pre-phase for every other suite. Checked
+ # BEFORE the plain TOOLS_TRIGGER flow (disjoint trigger
+ # phrase — the phase-71/72/94/118 ordering convention; the
+ # trigger needs the clause, so deflected turns never hit it).
+ show_image = _show_image_flow(body)
+ if show_image is not None:
+ if show_image[0] == "call":
+ stream = _tool_call_stream(
+ "show_image", {"path": show_image[1]}, "call_show"
+ )
+ else: # "answer" — the final answer + the tail echo
+ block = _DOCUMENTS_BLOCK_RE.search(_system(body))
+ tail = (block.group(0) if block else _context(body))[-160:]
+ stream = _sse_stream(
+ _apply_max_tokens(
+ "Here is the picture you asked about — …and the very "
+ f"end of my notes reads: “{tail}” "
+ "(Deterministic mock answer for E2E.)",
+ body.get("max_tokens"),
+ ),
+ 0.0,
+ )
+ return StreamingResponse(
+ stream,
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
# Phase 109 (task 03, never-frozen-turn story suite): the
# deterministic REPORTED-REPRO turn (delta → tool →
# thinking-after-delta — TODO.md L3): the scripted first answer,
diff --git a/tests/e2e/test_answer_images.py b/tests/e2e/test_answer_images.py
new file mode 100644
index 0000000..f97e84a
--- /dev/null
+++ b/tests/e2e/test_answer_images.py
@@ -0,0 +1,971 @@
+"""Phase 124 E2E (Playwright): image source chips + LLM-chosen answer images.
+
+Run in isolation (DB must be up: ``podman compose up -d db``):
+
+ uv run pytest tests/e2e/test_answer_images.py -v --no-cov
+
+The suite proves BOTH halves of TODO.md L3 on the real user path, plus
+the toggle-off contract — two module apps on dedicated ports (the
+phase-122 ``test_image_documents.py`` pattern), sharing one deterministic
+mock LLM and the shared e2e Postgres (truncate/re-import per app):
+
+* **App A (feature ON)** — ``BOR_IMAGES=true`` +
+ ``BOR_ANSWER_IMAGES=true``: the standalone PNG arrives through the
+ REAL admin upload flow + the RAG page's "Sync sources" (the mock's
+ vision model writes the byte-stable description), then:
+ - **Feature flow** — the ``show me the picture of …`` trigger (the
+ mock's phase-124 scripted trigger, ``tests/e2e/mock_llm.py``): the
+ mock scripts ONLY a ``show_image`` call (NO ``read`` — the ZERO-READ
+ turn, the shape that makes the chip assertion meaningful). The
+ brain bubble shows the ANSWER FIGURE (``.answer-image`` — ``
``
+ from the bytes route, ``loading="lazy"``, below the text and above
+ the chip row per A4, alt/caption settling to the summary), the
+ answer TEXT is still streamed in full (the tail echo of the
+ ```` seed proves the description was in the prompt), the
+ source-chip row carries the image doc's chip + its phase-122
+ figure (the AUTO-CITE proof — the mock made zero ``read`` calls,
+ asserted on the mock's request capture), and the "Nearby docs" row
+ does not repeat the doc. The wire pin: the first model request
+ offered FOUR tools (the three base + ``show_image``) and the turn
+ executed exactly one ``show_image`` — the saved brain record
+ persists the ``answer_image`` ref.
+ - **Restore + share** — a second feature turn, then a page RELOAD:
+ the restored brain bubble shows the figure again (the
+ ``answer_image`` persistence through the ``bor.chat.v1`` restore
+ path), and the shared link (the existing chat-page Share flow, a
+ fresh anonymous context) renders the figure on the shared page.
+ - **Bug-fix-only flow** — the ``which diagram is in your notes``
+ trigger (the mock answers straight from the seed with ZERO tool
+ calls — no ``show_image``, no ``read``): the source-chip row STILL
+ carries the image doc's chip + figure (the bug fix on the pure
+ zero-read path — the assertion the owner's report describes), the
+ answer text quotes the seeded description's tail, and NO answer
+ figure renders (the model never called the tool).
+* **App B (default off)** — ``BOR_IMAGES=true``, ``BOR_ANSWER_IMAGES``
+ pinned to the code default ``false`` (EXPLICITLY — process env ranks
+ above an operator's local gitignored ``.env``, so the default-off
+ contract under test cannot leak in, the phase-122 default-app
+ convention; the production default is unset = the same ``False``):
+ the same feature trigger question → the clause is absent from the
+ prompt, so the mock's trigger NEVER fires — the mock never sees
+ ``show_image`` among the offered tools (the request capture carries
+ exactly the THREE base tools), the brain bubble has NO answer
+ figure, and the saved brain record carries NO ``answer_image`` key
+ (the byte-identical-off contract). The bug-fix trigger STILL chips
+ the image doc (the chips fix is NOT gated by ``BOR_ANSWER_IMAGES`` —
+ it belongs to the ``BOR_IMAGES``-indexed docs, per the phase design).
+
+DB isolation: the suites share one Postgres; ``saved_chats`` survives
+the truncation (the house pattern), so every question text here is
+UNIQUE (its auto-title is therefore unique) and the row lookups key on
+it. App B's module seed truncates the KB tables App A's tests used —
+App B's tests run AFTER App A's (file order) and never look back.
+"""
+from __future__ import annotations
+
+import base64
+import json
+import os
+import re
+import subprocess
+import sys
+import time
+import zipfile
+from collections.abc import Iterator
+from pathlib import Path
+from typing import Any
+
+import httpx
+import pytest
+from playwright.sync_api import Browser, Page, expect
+from sqlalchemy import select, text
+
+from app.config import Settings
+from app.db import SessionLocal
+from app.models import Document
+from e2e.auth_helpers import login
+from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
+from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
+
+REPO = Path(__file__).resolve().parents[2]
+GIT_SOURCES_URL = "/git-sources.html"
+SOURCE_NAME = "e2e-answer-img" # the archive stem (archive_source_name)
+DOC_PATH = "pic.png"
+
+#: The mock's deterministic answer tail (every composed answer ends
+#: with it — the turn-settled wait signal, the house convention).
+MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
+#: The TAIL of the fixture description (``IMAGE_DESCRIPTION_ANSWER`` —
+#: the image doc's entire content): both phase-124 mock answers quote
+#: it (the feature flow's ```` tail echo, the bug-fix flow's
+#: block-content tail) — asserting it proves the SEED was in the
+#: prompt and reached the rendered answer.
+DESCRIPTION_TAIL = "Title: Homelab Network Map."
+
+#: App A (feature ON) — the ``show_image`` trigger question (cosine
+#: 0.4552 against the mock's token-overlap embeddings, well over the
+#: mock-calibrated 0.30 gate). Unique auto-title (the DB is shared).
+FEATURE_QUESTION = "Show me the picture of the homelab network diagram?"
+#: The restore/share flow's variant (the trigger substring is kept —
+#: cosine 0.4358, still over the gate).
+RESTORE_QUESTION = "Show me the picture of the homelab network diagram? (restore)"
+#: App B (default off) — the feature trigger's variant (the clause is
+#: absent, so the mock answers with the plain composed answer — cosine
+#: 0.4187, the turn still grounds).
+FEATURE_OFF_QUESTION = (
+ "Show me the picture of the homelab network diagram? (toggle off)"
+)
+#: The bug-fix-only trigger question (cosine 0.3410): the mock answers
+#: straight from the seed with ZERO tool calls and quotes the image
+#: doc's block tail — the chip must appear anyway (the auto-cite).
+DIAGRAM_QUESTION = "Which diagram is in your notes about the homelab network?"
+#: App B's bug-fix variant (the chips fix is not toggle-gated — cosine
+#: 0.3113, still over the mock-calibrated gate).
+DIAGRAM_OFF_QUESTION = (
+ "Which diagram is in your notes about the homelab network? (default off)"
+)
+
+#: A real 1×1 transparent PNG (the unit/integration suites' fixture —
+#: the pipeline is content-agnostic, the well-formed bytes keep the
+#: upload + serve + render path honest).
+PNG_1X1 = base64.b64decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
+ "AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
+)
+
+UPLOAD_TIMEOUT_MS = 30_000
+SYNC_TIMEOUT_MS = 60_000
+SYNCED_LABEL = re.compile(r"^Synced \d{1,2}:\d{2}$")
+
+
+# ---------------------------------------------------------------------------
+# Module apps: feature ON (App A) and default-OFF (App B). Each owns
+# its port + its scratch upload/source/image dirs.
+# ---------------------------------------------------------------------------
+
+
+def _app_env(
+ mock_llm: int, app_port: int, *, answer_images: str, scratch: Path
+) -> dict[str, str]:
+ """The conftest app env (leak guards included) with the phase-124
+ knobs: BOTH apps index images (``BOR_IMAGES=true`` — the fixture's
+ PNG must become a document on either), and ``BOR_ANSWER_IMAGES``
+ is pinned EXPLICITLY per app (``true`` for App A, ``false`` for
+ App B — process env ranks above an operator's local gitignored
+ ``.env``, so the contract under test cannot leak in either
+ direction, the phase-122 default-app convention; App B's explicit
+ ``false`` IS the production default — unset is ``False``). The
+ image/upload homes are in the suite's scratch dir (the app under
+ test must not write image copies into the owner's real
+ ``~/bor-sources``)."""
+ env = dict(os.environ)
+ env.pop("DEBUGPY", None)
+ env["BOR_ENVIRONMENT"] = "e2e"
+ env["BOR_STATIC_DIR"] = str(REPO / "frontend")
+ env["BOR_LLM_BASE_URL"] = (
+ "https://aipi.reeseapps.com/v1"
+ if USE_REAL_LLM
+ else f"http://127.0.0.1:{mock_llm}/v1"
+ )
+ # Mock-calibrated gate (conftest pattern) — both trigger questions
+ # ground on these values (see the question constants above).
+ env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
+ env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
+ env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
+ # Phase 67: instant retry waits + the code-default budget.
+ env["BOR_LLM_RETRY_DELAY"] = "0"
+ env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
+ env.setdefault(
+ "BOR_DATABASE_URL",
+ "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
+ )
+ env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
+ env["BOR_SESSION_SECRET"] = SESSION_SECRET
+ # Leak guards (conftest pattern).
+ env["BOR_GIT_SOURCES"] = ""
+ env["BOR_DOCS_REPO"] = ""
+ env["BOR_SUGGESTIONS"] = json.dumps(
+ Settings.model_fields["suggestions"].default
+ )
+ env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
+ env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
+ # Phase 124: both apps index images; only the ANSWER side differs.
+ env["BOR_IMAGES"] = "true"
+ env["BOR_ANSWER_IMAGES"] = answer_images
+ env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
+ env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
+ env["BOR_IMAGE_DIR"] = str(scratch / "images")
+ return env
+
+
+@pytest.fixture(scope="module")
+def app_server(mock_llm: int, tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
+ """App A (feature ON) — ``BOR_IMAGES=true`` +
+ ``BOR_ANSWER_IMAGES=true`` (the conftest session app is never
+ started in this isolated run, so no port clash)."""
+ scratch = tmp_path_factory.mktemp("bor_answer_img_on")
+ port = int(os.environ.get("E2E_APP_PORT_ANSWER_IMG_ON", "8162"))
+ proc = subprocess.Popen(
+ [sys.executable, "-m", "uvicorn", "app.main:app",
+ "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
+ cwd=REPO,
+ env=_app_env(mock_llm, port, answer_images="true", scratch=scratch),
+ )
+ try:
+ _wait_http(f"http://127.0.0.1:{port}/api/health")
+ yield f"http://127.0.0.1:{port}"
+ finally:
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+
+
+@pytest.fixture(scope="module")
+def app_url(app_server: str) -> str:
+ return app_server
+
+
+@pytest.fixture(scope="module")
+def default_app_server(
+ mock_llm: int, tmp_path_factory: pytest.TempPathFactory
+) -> Iterator[str]:
+ """App B (default off) — ``BOR_IMAGES=true`` +
+ ``BOR_ANSWER_IMAGES=false`` pinned explicitly (the production
+ default — the toggle-off contract; only the negative tests start
+ it, and they run AFTER App A's — file order)."""
+ scratch = tmp_path_factory.mktemp("bor_answer_img_off")
+ port = int(os.environ.get("E2E_APP_PORT_ANSWER_IMG_OFF", "8163"))
+ proc = subprocess.Popen(
+ [sys.executable, "-m", "uvicorn", "app.main:app",
+ "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
+ cwd=REPO,
+ env=_app_env(mock_llm, port, answer_images="false", scratch=scratch),
+ )
+ try:
+ _wait_http(f"http://127.0.0.1:{port}/api/health")
+ yield f"http://127.0.0.1:{port}"
+ finally:
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+
+
+@pytest.fixture(scope="module")
+def default_app_url(default_app_server: str) -> str:
+ return default_app_server
+
+
+@pytest.fixture(scope="module")
+def zip_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ """The fixture archive: ONE standalone PNG at the root — source
+ ``e2e-answer-img``, document path ``pic.png`` (the mock's seeded-
+ image-doc pick is deterministic: the only image-extension block)."""
+ root = tmp_path_factory.mktemp("bor_answer_img_zip")
+ (root / DOC_PATH).write_bytes(PNG_1X1)
+ archive = root / f"{SOURCE_NAME}.zip"
+ with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
+ zf.write(root / DOC_PATH, arcname=DOC_PATH)
+ return archive
+
+
+# ---------------------------------------------------------------------------
+# DB + UI + mock-capture helpers
+# ---------------------------------------------------------------------------
+
+
+def _truncate_all() -> None:
+ """Fresh KB + registry (the E2E isolation pattern): the suites
+ share one Postgres, and a leftover row would corrupt the counts.
+ ``sources_meta`` (the KB generation counter) resets with the KB —
+ the sync re-creates the row. ``saved_chats`` is never touched by
+ the truncation — the unique question texts keep the row lookups
+ unambiguous, and :func:`_cleanup_saved_rows` owns this suite's
+ rows (re-runnable on the shared DB)."""
+ with SessionLocal() as db:
+ db.execute(text(
+ "TRUNCATE chunks, documents, query_log, kb_overview, "
+ "git_sources, sources_meta, folder_summaries"
+ ))
+ db.commit()
+
+
+def _health_db(app_url: str) -> bool:
+ try:
+ return httpx.get(f"{app_url}/api/health", timeout=5).json()["db"] == "up"
+ except Exception: # noqa: BLE001 — unreachable is the skip case
+ return False
+
+
+def _mock_capture(mock_llm: int) -> list[dict[str, Any]]:
+ """The mock's recent chat-completions request bodies (oldest
+ first) — the wire lens on the app's tool OFFER and the turn's
+ tool CALLS (the mock is the only E2E lens on the LLM's context)."""
+ r = httpx.get(f"http://127.0.0.1:{mock_llm}/v1/e2e/captured", timeout=5)
+ assert r.status_code == 200
+ return r.json()
+
+
+def _mock_capture_reset(mock_llm: int) -> None:
+ r = httpx.post(f"http://127.0.0.1:{mock_llm}/v1/e2e/captured/reset", timeout=5)
+ assert r.status_code == 200
+
+
+def _executed_tool_calls(captured: list[dict[str, Any]]) -> list[str]:
+ """Every tool call the MODEL made across *captured* requests (the
+ assistant ``tool_calls`` messages), in order — the executed-call
+ lens (a refusal still appears here; it is the model's call)."""
+ return [
+ tc["function"]["name"]
+ for body in captured
+ for m in body["messages"]
+ if m.get("role") == "assistant" and m.get("tool_calls")
+ for tc in m["tool_calls"]
+ ]
+
+
+def _offered_tool_names(body: dict[str, Any]) -> list[str]:
+ return [t["function"]["name"] for t in (body.get("tools") or [])]
+
+
+def _upload_zip(page: Page, app_url: str, archive: Path) -> None:
+ """The real admin upload flow: form login → the git-sources page →
+ pick the archive → submit → the terminal result line (phase 90:
+ "Uploaded — press Sync sources to import it.")."""
+ login(page, app_url, next=GIT_SOURCES_URL)
+ expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
+ expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
+ page.set_input_files("#archive-upload-file", str(archive))
+ page.click("#archive-upload-btn")
+ result = page.locator("#archive-upload-result")
+ expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
+ expect(result).to_have_text(
+ f"Uploaded {SOURCE_NAME} — press Sync sources to import it.",
+ timeout=UPLOAD_TIMEOUT_MS,
+ )
+
+
+def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
+ """The RAG page's "Sync sources" button (the scan — phase 90 A3):
+ click → "Syncing…" → the terminal "Synced HH:MM" label + the fresh
+ counts in #sync-result (the never-stale contract)."""
+ page.goto(app_url + "/sources.html")
+ btn = page.locator("#sync-btn")
+ expect(btn).to_be_visible(timeout=30_000)
+ expect(page.locator("#sync-label")).to_have_text(
+ re.compile(r"^Sync sources$|^Synced \d{1,2}:\d{2}$")
+ )
+ btn.click()
+ expect(btn).to_be_disabled()
+ expect(page.locator("#sync-label")).to_have_text("Syncing…")
+ expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
+ expect(btn).to_be_enabled()
+ expect(page.locator("#sync-result")).to_have_text(expected_result)
+
+
+def _seed_image_doc(
+ app_url: str, browser: Browser, zip_path: Path
+) -> dict[str, str]:
+ """Upload the single-PNG zip through the admin UI, then scan it
+ with the RAG page's Sync button (the mock's vision model describes
+ the image in-process). Returns the seeded doc's identity (the
+ bytes route + the description + the title) for the render
+ assertions. The app's ``/api/config`` flag is asserted first — the
+ env override must have reached the app under test, or the whole
+ premise of the suite is void."""
+ cfg = httpx.get(f"{app_url}/api/config", timeout=5).json()
+ assert cfg["images"] is True, "the module app must run with BOR_IMAGES=true"
+ _truncate_all()
+ page = browser.new_page(viewport={"width": 1280, "height": 800})
+ try:
+ _upload_zip(page, app_url, zip_path)
+ _sync_via_ui(page, app_url, expected_result="1 added")
+ with SessionLocal() as db:
+ doc = db.scalar(
+ select(Document).where(
+ Document.source == SOURCE_NAME, Document.path == DOC_PATH
+ )
+ )
+ assert doc is not None, "the uploaded image must become a document"
+ assert doc.is_image is True and doc.image_path is not None
+ # content == summary == the mock's description (the ONLY
+ # embedded text of the doc — the pipeline contract, phase 122).
+ assert doc.content == IMAGE_DESCRIPTION_ANSWER
+ assert doc.summary == IMAGE_DESCRIPTION_ANSWER
+ return {
+ "image_url": f"/api/documents/{doc.id}/image",
+ "description": IMAGE_DESCRIPTION_ANSWER,
+ "title": doc.title,
+ }
+ finally:
+ page.close()
+
+
+@pytest.fixture(scope="module")
+def seeded(
+ app_url: str,
+ browser: Browser,
+ zip_path: Path,
+) -> Iterator[dict[str, str]]:
+ """App A's KB: the fixture image doc through the real UI path."""
+ if not _health_db(app_url):
+ pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
+ _cleanup_saved_rows() # a previous run's rows would be stale (new doc id)
+ yield _seed_image_doc(app_url, browser, zip_path)
+ _cleanup_saved_rows()
+ _truncate_all()
+
+
+@pytest.fixture(scope="module")
+def seeded_off(
+ default_app_url: str,
+ browser: Browser,
+ zip_path: Path,
+) -> Iterator[dict[str, str]]:
+ """App B's KB: the SAME fixture image doc through the SAME real
+ UI path (images indexed, the answer feature OFF). Runs AFTER all
+ of App A's tests (file order) — its truncation wipes App A's KB
+ tables, and App B's tests never look back."""
+ if not _health_db(default_app_url):
+ pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
+ _cleanup_saved_rows()
+ yield _seed_image_doc(default_app_url, browser, zip_path)
+ _cleanup_saved_rows()
+ _truncate_all()
+
+
+# ---------------------------------------------------------------------------
+# Saved-chat + share helpers (the test_share_chat.py conventions)
+# ---------------------------------------------------------------------------
+
+
+def _admin_cookies(page: Page) -> dict[str, str]:
+ """The signed session cookies the browser holds after a form login
+ — used to call the admin API with plain httpx (the test's API side
+ sees exactly what the signed-in browser sees)."""
+ return {
+ c["name"]: c["value"]
+ for c in page.context.cookies()
+ if "name" in c and "value" in c
+ }
+
+
+def _auto_title(question: str) -> str:
+ """The phase-50 auto-title convention: the first question,
+ whitespace-collapsed, capped at 120 chars."""
+ return " ".join(question.split())[:120]
+
+
+#: This suite's auto-titles (the shared e2e Postgres keeps
+#: ``saved_chats`` across runs — the test_share_chat cleanup
+#: concern): a re-run must not find the previous run's row (same
+#: title, but the KB re-seed minted a NEW doc id — the row's
+#: ``answer_image.image_url`` would no longer match the seed).
+_SUITE_TITLES = tuple(
+ _auto_title(q)
+ for q in (
+ FEATURE_QUESTION,
+ RESTORE_QUESTION,
+ FEATURE_OFF_QUESTION,
+ DIAGRAM_QUESTION,
+ DIAGRAM_OFF_QUESTION,
+ )
+)
+
+
+def _cleanup_saved_rows() -> None:
+ """Delete THIS suite's saved rows by title (the house pattern —
+ test_share_chat deletes its own rows; here the module-boundary
+ fixture calls it, so a re-run starts clean). Best-effort: a
+ failed delete (DB unreachable — the skip case) never masks the
+ skip; a failed delete with the DB up just re-creates the stale-
+ row premise, and the suite fails loudly on the row assertion."""
+ try:
+ with SessionLocal() as db:
+ db.execute(
+ text("DELETE FROM saved_chats WHERE title = ANY(:titles)"),
+ {"titles": list(_SUITE_TITLES)},
+ )
+ db.commit()
+ except Exception: # noqa: BLE001 — DB unreachable: the skip handles it
+ pass
+
+
+def _wait_saved_row(
+ app_url: str,
+ cookies: dict[str, str],
+ title: str,
+ messages: int = 2,
+) -> dict[str, Any]:
+ """Wait for the auto-saved row (phase 55: auto-saves are SILENT —
+ A2 — so there is no status line to wait on). The upsert is
+ fire-and-forget from the UI's point of view, so poll the admin
+ list until the row with the conversation's auto-title appears with
+ the expected message count."""
+ deadline = time.monotonic() + 15
+ last: dict[str, Any] | None = None
+ while time.monotonic() < deadline:
+ r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
+ assert r.status_code == 200
+ rows = r.json()["chats"]
+ last = next((c for c in rows if c["title"] == title), None)
+ if last is not None and last["message_count"] >= messages:
+ return last
+ time.sleep(0.2)
+ raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
+
+
+def _chat_messages(
+ app_url: str, cookies: dict[str, str], chat_id: str
+) -> list[dict[str, Any]]:
+ r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
+ assert r.status_code == 200
+ return r.json()["messages"]
+
+
+def _brain_record(messages: list[dict[str, Any]]) -> dict[str, Any]:
+ brains = [m for m in messages if m.get("who") == "brain"]
+ assert brains, f"no brain record in the saved conversation: {messages!r}"
+ return brains[-1]
+
+
+def _grant_clipboard(page: Page, app_url: str) -> None:
+ """Grant the async-clipboard permissions on the admin context
+ (test_share_chat.py — headless Chromium still requires the grant
+ before ``writeText`` resolves on a secure origin)."""
+ page.context.grant_permissions(
+ ["clipboard-read", "clipboard-write"], origin=app_url
+ )
+
+
+def _click_share(app_url: str, page: Page) -> None:
+ """Press the chat page's Share pill and pin the owner-locked
+ outcome on the live region (the test_share_chat.py branch —
+ clipboard success vs the inline-link fallback)."""
+ page.locator("#share-chat-btn").click()
+ if page.evaluate("() => !!navigator.clipboard"):
+ expect(page.locator("#send-status")).to_have_text(
+ "Share link copied.", timeout=15_000
+ )
+ else:
+ expect(page.locator("#send-status")).to_have_text(
+ "Share link ready — copy it from the field.", timeout=15_000
+ )
+ expect(page.locator(".share-link-fallback")).to_be_visible()
+
+
+def _assert_answer_figure(
+ page: Page,
+ app_url: str,
+ seeded: dict[str, str],
+ *,
+ caption: str,
+ last_brain: str = ".msg.brain",
+) -> None:
+ """The A4 answer-figure contract in one place (the live, restore,
+ and shared surfaces share the shape — the shared page's figure is
+ a plain ```, the chat page's an anchor, but the inner
+ ``img``/caption classes are the same): exactly ONE ``.answer-image``
+ in the brain bubble, its ``
`` from the bytes route
+ (``loading="lazy"``, visible — the bytes really serve), and the
+ caption/alt settled to *caption* (the summary on the admin
+ surfaces, the title on the guest one)."""
+ fig = page.locator(f"{last_brain} .answer-image")
+ expect(fig).to_have_count(1)
+ img = fig.locator("img")
+ expect(img).to_have_count(1)
+ expect(img).to_be_visible(timeout=15_000)
+ expect(img).to_have_attribute("src", seeded["image_url"])
+ expect(img).to_have_attribute("loading", "lazy")
+ expect(fig.locator(".answer-image-caption")).to_have_text(
+ caption, timeout=15_000
+ )
+ # The served bytes are a real image (the public bytes route).
+ resp = httpx.get(app_url + seeded["image_url"], timeout=10)
+ assert resp.status_code == 200
+ assert resp.headers["content-type"].startswith("image/")
+ assert resp.content == PNG_1X1
+
+
+def _assert_image_chip(
+ page: Page, seeded: dict[str, str], *, last_brain: str = ".msg.brain"
+) -> None:
+ """The citation chip the AUTO-CITE earns (phase 124 task 05): the
+ image doc's text chip + its phase-122 compact figure (``
``
+ from the bytes route, alt settling to the summary — the admin
+ surface; the shared page's chip is plain text and asserted
+ separately)."""
+ chip = page.locator(f"{last_brain} .source-chip")
+ expect(chip).to_have_count(1)
+ expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
+ fig = page.locator(f"{last_brain} .source-image")
+ expect(fig).to_have_count(1)
+ img = fig.locator(".source-image-img")
+ expect(img).to_be_visible(timeout=15_000)
+ expect(img).to_have_attribute("src", seeded["image_url"])
+ expect(img).to_have_attribute("alt", seeded["description"], timeout=15_000)
+
+
+def _body_child_classes(page: Page) -> list[str]:
+ """The LAST brain bubble's ``.msg-body`` direct children's class
+ names, in DOM order — the A4 position pin (bubble < answer-image
+ < msg-meta)."""
+ return page.evaluate(
+ """() => {
+ const body = [...document.querySelectorAll(".msg.brain")].pop()
+ .querySelector(".msg-body");
+ return [...body.children].map((el) => el.className);
+ }"""
+ )
+
+
+# ---------------------------------------------------------------------------
+# 1. App A (feature ON) — the feature flow: the scripted show_image
+# turn (ZERO reads) renders the answer figure + the auto-cited chip
+# ---------------------------------------------------------------------------
+
+
+def test_feature_flow_answer_figure_chips_and_zero_reads(
+ page: Page, app_url: str, seeded: dict[str, str], mock_llm: int
+) -> None:
+ """The ``show me the picture of …`` trigger: the mock scripts ONLY
+ a ``show_image`` call (NO ``read`` — the zero-read turn). Asserts:
+ (1) the brain bubble's ``.msg-body`` carries ``.answer-image`` with
+ an ``
`` from the bytes route (200, image Content-Type) and
+ ``loading="lazy"``; (2) the figure sits BEFORE the ``.msg-meta``
+ source-chip row (A4); (3) the answer TEXT is non-empty and quotes
+ the seed's tail (the "summaries still provided" contract + the
+ echo proving the seed was in the prompt); (4) the source-chip row
+ carries the image doc's chip + its figure (the AUTO-CITE proof —
+ the mock made ZERO ``read`` calls: only ``show_image`` — asserted
+ on the wire); (5) the "Nearby docs" row does NOT repeat the doc.
+ Plus the persistence pin: the saved brain record carries the
+ ``answer_image`` ref (the task-04 frame field, round-tripped)."""
+ login(page, app_url, next="/")
+ expect(page.locator("#kb-banner")).to_be_hidden()
+
+ _mock_capture_reset(mock_llm)
+ page.fill("#message-input", FEATURE_QUESTION)
+ page.click("#send-btn")
+
+ bubble = page.locator(".msg.brain .bubble").last
+ # The answer TEXT is non-empty (the "summaries still provided"
+ # contract) — the tail echo lands LAST (the terminal signal): it
+ # quotes the seed's tail, i.e. the description.
+ expect(bubble).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
+ expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
+ expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
+ expect(page.locator("#send-label")).to_have_text("Send")
+
+ # (1) The answer figure: the bytes route, lazy, alt/caption the
+ # summary (the fetchContentSummary pattern settles both).
+ _assert_answer_figure(page, app_url, seeded, caption=seeded["description"])
+
+ # (2) A4 position: below the answer text, above the chip row.
+ classes = _body_child_classes(page)
+ assert "bubble" in classes, f"no answer bubble in .msg-body: {classes}"
+ assert "answer-image" in classes, f"no .answer-image in .msg-body: {classes}"
+ assert "msg-meta" in classes, f"no chip row in .msg-body: {classes}"
+ assert (
+ classes.index("bubble")
+ < classes.index("answer-image")
+ < classes.index("msg-meta")
+ ), f"A4 order violated (bubble < answer-image < msg-meta): {classes}"
+
+ # (4) The chip row carries the image doc's chip + its figure —
+ # the AUTO-CITE (the model never read it; the seed IS its
+ # entire content, so suggested == read for it).
+ _assert_image_chip(page, seeded)
+
+ # (5) The "Nearby docs" row does NOT repeat the image doc — the
+ # related tier excludes cited members (the single-doc KB has
+ # no related tier at all: the row is absent).
+ expect(page.locator(".msg.brain .related-docs")).to_have_count(0)
+
+ # The WIRE pin: exactly two model calls (the show_image round +
+ # the final answer), the FIRST request offered the FOUR tools
+ # (three base + show_image — the toggle-ON offer gate), and the
+ # turn executed EXACTLY one tool call: show_image — NO read (the
+ # zero-read turn that makes the chip assertion meaningful).
+ captured = _mock_capture(mock_llm)
+ assert len(captured) == 2, (
+ f"expected exactly 2 model calls (call + answer), got {len(captured)}"
+ )
+ assert _offered_tool_names(captured[0]) == ["ls", "read", "grep", "show_image"]
+ assert _executed_tool_calls(captured) == ["show_image"]
+ tool_results = [
+ m.get("content") or ""
+ for m in captured[1]["messages"]
+ if m.get("role") == "tool"
+ ]
+ assert any("Image shown to the user" in r for r in tool_results), (
+ f"the confirming tool result never reached the model: {tool_results}"
+ )
+
+ # The persistence pin: the auto-saved brain record carries the
+ # answer_image ref (source/path/title/image_url — the task-04
+ # frame field; the restore + shared flows assert on its render).
+ row = _wait_saved_row(app_url, _admin_cookies(page), _auto_title(FEATURE_QUESTION))
+ record = _brain_record(_chat_messages(app_url, _admin_cookies(page), str(row["id"])))
+ assert record.get("answer_image") == {
+ "source": SOURCE_NAME,
+ "path": DOC_PATH,
+ "title": seeded["title"],
+ "image_url": seeded["image_url"],
+ }, f"the brain record's answer_image is wrong: {record.get('answer_image')!r}"
+
+
+# ---------------------------------------------------------------------------
+# 2. App A — reload restores the figure; the shared page shows it
+# ---------------------------------------------------------------------------
+
+
+def test_reload_restores_figure_and_shared_page_shows_it(
+ page: Page,
+ app_url: str,
+ seeded: dict[str, str],
+ mock_llm: int,
+ browser: Browser,
+) -> None:
+ """A second feature turn (a unique question — unique auto-title on
+ the shared DB), then a page RELOAD: the restored brain bubble
+ shows the figure again (the ``answer_image`` persistence through
+ the ``bor.chat.v1`` restore path — pixel-parity with the live
+ render), and the existing Share flow's link — opened in a FRESH
+ anonymous context — renders the figure on the shared page (the
+ bytes route is public; the guest caption keeps the title — the
+ content endpoint is user-gated, so the summary fetch is skipped
+ there)."""
+ login(page, app_url, next="/")
+ expect(page.locator("#kb-banner")).to_be_hidden()
+
+ _mock_capture_reset(mock_llm)
+ page.fill("#message-input", RESTORE_QUESTION)
+ page.click("#send-btn")
+
+ bubble = page.locator(".msg.brain .bubble").last
+ expect(bubble).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
+ expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
+ expect(page.locator("#send-label")).to_have_text("Send")
+ _assert_answer_figure(page, app_url, seeded, caption=seeded["description"])
+ _assert_image_chip(page, seeded)
+
+ # The auto-save settled (the row exists with both messages) before
+ # the reload — the Share click later shares the linked row, or
+ # re-creates it unlinked (either path is owner-locked; the row
+ # lookup keys on the unique auto-title).
+ cookies = _admin_cookies(page)
+ _wait_saved_row(app_url, cookies, _auto_title(RESTORE_QUESTION))
+
+ # RELOAD: the restored brain bubble shows the figure again — the
+ # localStorage restore (rememberBrainTurn's save point ran
+ # synchronously in the done handler, before the send button
+ # re-enabled, so the record is durable before we reload).
+ page.reload()
+ expect(page.locator("#kb-banner")).to_be_hidden()
+ restored = page.locator(".msg.brain .bubble").last
+ expect(restored).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
+ expect(restored).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
+ _assert_answer_figure(page, app_url, seeded, caption=seeded["description"])
+ _assert_image_chip(page, seeded)
+
+ # SHARE: the existing chat-page Share flow → a fresh anonymous
+ # context sees the figure (and the plain-text chip).
+ _grant_clipboard(page, app_url)
+ _click_share(app_url, page)
+ # The share landed on the pre-existing row (the linked branch —
+ # the idempotent share) or a fresh row (the unlinked save-and-
+ # share) — either carries the token; the unique auto-title keeps
+ # the lookup unambiguous on the shared DB.
+ r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
+ rows = [
+ c for c in r.json()["chats"]
+ if c["title"] == _auto_title(RESTORE_QUESTION)
+ ]
+ token_row = next((c for c in rows if c.get("share_url")), None)
+ assert token_row is not None and token_row.get("share_url"), (
+ f"no share_url on any shared row: {rows!r}"
+ )
+ share_link = str(token_row["share_url"])
+ assert share_link.startswith("/shared/")
+
+ anon = browser.new_page(viewport={"width": 1280, "height": 800})
+ try:
+ anon.goto(app_url + share_link)
+ expect(anon.locator("#shared-title")).to_have_text(
+ _auto_title(RESTORE_QUESTION), timeout=30_000
+ )
+ expect(anon.locator(".msg.brain .bubble").last).to_contain_text(
+ DESCRIPTION_TAIL, timeout=30_000
+ )
+ # The shared page renders the figure — the guest caption keeps
+ # the TITLE (no summary fetch — the content endpoint is
+ # user-gated), the img is from the public bytes route.
+ _assert_answer_figure(anon, app_url, seeded, caption=seeded["title"])
+ # The shared chip is PLAIN TEXT (guests cannot open documents):
+ chip = anon.locator(".msg.brain .source-chip")
+ expect(chip).to_have_count(1)
+ expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
+ # The shared page is zero-controls: the figure is a plain
+ # , never an anchor (the owner-locked contract).
+ assert anon.locator(".msg.brain a.answer-image").count() == 0
+ finally:
+ anon.close()
+
+
+# ---------------------------------------------------------------------------
+# 3. App A — the bug-fix-only flow: ZERO tool calls, the chip STILL
+# appears (the pure zero-read path — the owner's report)
+# ---------------------------------------------------------------------------
+
+
+def test_bugfix_only_flow_zero_tool_calls_still_chips(
+ page: Page, app_url: str, seeded: dict[str, str], mock_llm: int
+) -> None:
+ """The ``which diagram is in your notes`` trigger: the mock
+ answers straight from the seed with ZERO tool calls (no
+ ``show_image`` — the clause's flow is not triggered — and no
+ ``read``): the source-chip row STILL carries the image doc's chip
+ + figure (the bug fix on the pure zero-read path — the assertion
+ the owner's report describes), the answer text quotes the seeded
+ description's tail (the description WAS in the prompt), and NO
+ answer figure renders (the model never called the tool)."""
+ login(page, app_url, next="/")
+ expect(page.locator("#kb-banner")).to_be_hidden()
+
+ _mock_capture_reset(mock_llm)
+ page.fill("#message-input", DIAGRAM_QUESTION)
+ page.click("#send-btn")
+
+ bubble = page.locator(".msg.brain .bubble").last
+ # The answer quotes the seeded image doc's block tail (the
+ # description's end — the seed was in the prompt) and settles.
+ expect(bubble).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
+ expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
+ expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
+ expect(page.locator("#send-label")).to_have_text("Send")
+
+ # ZERO tool calls — on the wire (exactly ONE model call, no
+ # tool_calls message at all) and in the DOM (no tool record).
+ captured = _mock_capture(mock_llm)
+ assert len(captured) == 1, (
+ f"the bug-fix flow must make exactly 1 model call, got {len(captured)}"
+ )
+ assert _executed_tool_calls(captured) == [], (
+ f"the bug-fix flow must make ZERO tool calls: {_executed_tool_calls(captured)}"
+ )
+ expect(page.locator(".msg.brain .tool-calls-disclosure")).to_have_count(0)
+
+ # NO answer figure (the model never called show_image) — but the
+ # chip row STILL carries the image doc's chip + figure (the auto-
+ # cite: suggested == read for an image doc, no read performed).
+ expect(page.locator(".msg.brain .answer-image")).to_have_count(0)
+ _assert_image_chip(page, seeded)
+
+
+# ---------------------------------------------------------------------------
+# 4. App B (default off) — the toggle-off contract: no show_image
+# offered, no answer figure, no answer_image key in the record
+# ---------------------------------------------------------------------------
+
+
+def test_toggle_off_flow_no_tool_no_answer_image_key(
+ page: Page,
+ default_app_url: str,
+ seeded_off: dict[str, str],
+ mock_llm: int,
+) -> None:
+ """The same feature trigger question on the default-OFF app: the
+ prompt clause is ABSENT, so the mock's trigger never fires (the
+ plain composed answer streams instead) — and the contract holds
+ at every surface: the mock NEVER sees ``show_image`` among the
+ offered tools (the request capture carries exactly the THREE base
+ tools), the brain bubble has NO ``.answer-image``, and the saved
+ brain record carries NO ``answer_image`` key (the byte-identical-
+ off contract — the key is absent, never null)."""
+ login(page, default_app_url, next="/")
+ expect(page.locator("#kb-banner")).to_be_hidden()
+
+ _mock_capture_reset(mock_llm)
+ page.fill("#message-input", FEATURE_OFF_QUESTION)
+ page.click("#send-btn")
+
+ bubble = page.locator(".msg.brain .bubble").last
+ expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
+ expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
+ expect(page.locator("#send-label")).to_have_text("Send")
+
+ # No answer figure anywhere in the brain bubble.
+ expect(page.locator(".msg.brain .answer-image")).to_have_count(0)
+
+ # The WIRE pin: the offer list is EXACTLY the three base tools
+ # (no show_image — the toggle-OFF gate on the offer), and the
+ # turn made no tool calls at all (the mock's feature trigger is
+ # clause-gated — it never fired, so the plain answer streamed in
+ # one call).
+ captured = _mock_capture(mock_llm)
+ assert len(captured) == 1, (
+ f"expected exactly 1 model call (no tool round), got {len(captured)}"
+ )
+ assert _offered_tool_names(captured[0]) == ["ls", "read", "grep"], (
+ f"show_image leaked into the default-off offer: "
+ f"{_offered_tool_names(captured[0])}"
+ )
+ assert _executed_tool_calls(captured) == []
+
+ # The saved brain record carries NO answer_image key (ABSENT — the
+ # byte-identical-off contract; the phase-113 422-silence pin is
+ # the positive half of this, in the App A flow + the integration
+ # suite). The chip, though, IS still auto-cited (the chips fix is
+ # not toggle-gated — asserted in the next test).
+ row = _wait_saved_row(
+ default_app_url,
+ _admin_cookies(page),
+ _auto_title(FEATURE_OFF_QUESTION),
+ )
+ record = _brain_record(
+ _chat_messages(default_app_url, _admin_cookies(page), str(row["id"]))
+ )
+ assert "answer_image" not in record, (
+ f"the default-off brain record must NOT carry answer_image: "
+ f"{record.get('answer_image')!r}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 5. App B (default off) — the bug-fix trigger STILL chips the image
+# doc (the chips fix belongs to BOR_IMAGES, not BOR_ANSWER_IMAGES)
+# ---------------------------------------------------------------------------
+
+
+def test_toggle_off_bugfix_trigger_still_chips(
+ page: Page, default_app_url: str, seeded_off: dict[str, str], mock_llm: int
+) -> None:
+ """The bug-fix trigger on the default-OFF app (the clause is
+ absent — the trigger does NOT require it): ZERO tool calls, the
+ answer quotes the seeded description's tail, and the source-chip
+ row STILL carries the image doc's chip + figure — the chips fix
+ is NOT gated by ``BOR_ANSWER_IMAGES`` (it belongs to the
+ ``BOR_IMAGES``-indexed docs, per the phase design)."""
+ login(page, default_app_url, next="/")
+ expect(page.locator("#kb-banner")).to_be_hidden()
+
+ _mock_capture_reset(mock_llm)
+ page.fill("#message-input", DIAGRAM_OFF_QUESTION)
+ page.click("#send-btn")
+
+ bubble = page.locator(".msg.brain .bubble").last
+ expect(bubble).to_contain_text(DESCRIPTION_TAIL, timeout=30_000)
+ expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
+ expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
+ expect(page.locator("#send-label")).to_have_text("Send")
+
+ captured = _mock_capture(mock_llm)
+ assert len(captured) == 1
+ assert _executed_tool_calls(captured) == []
+ expect(page.locator(".msg.brain .tool-calls-disclosure")).to_have_count(0)
+ expect(page.locator(".msg.brain .answer-image")).to_have_count(0)
+ _assert_image_chip(page, seeded_off)
diff --git a/tests/integration/test_chat_api.py b/tests/integration/test_chat_api.py
index 43c7f82..24388ec 100644
--- a/tests/integration/test_chat_api.py
+++ b/tests/integration/test_chat_api.py
@@ -2507,3 +2507,286 @@ def test_shared_chat_serves_the_user_image_path(
finally:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
+
+
+# ---------------------------------------------------------------------------
+# Phase 124, task 04 — the done frame's OPTIONAL answer_image (the
+# shown document's ref: the LLM's ``show_image`` pick) + the byte-
+# identical-off contract (every other turn: no key, no null).
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture()
+def answer_images_on(monkeypatch: pytest.MonkeyPatch) -> None:
+ """The chat endpoint's settings view with ``answer_images`` ON
+ (the ``BOR_ANSWER_IMAGES`` feature path; the monkeypatch fixture
+ reverts the patch, the conftest leak-guard pins the default OFF
+ for every other test in the module)."""
+ settings = Settings(
+ _env_file=None, # pyright: ignore[reportCallIssue]
+ answer_images=True,
+ )
+ monkeypatch.setattr(chat_api, "get_settings", lambda: settings)
+
+
+def test_show_image_turn_done_frame_carries_answer_image(
+ client, db, seeded_kb: FakeRagLLM, answer_images_on: None
+) -> None:
+ """A grounded turn whose mock client scripts a ``show_image`` tool
+ call on a seeded image doc (the scripted-tool mechanism — the
+ ``use your tools`` precedent) → the final SSE ``done`` frame JSON
+ carries ``answer_image`` with ``image_url ==
+ /api/documents//image`` and the cited ``sources`` include the
+ same doc (its scripted read — the phase-119 citation tier)."""
+ doc = _seed_image_doc(db)
+ scripted = FakeRagLLM(
+ tool_script=[
+ [
+ ToolCallPiece(
+ id="call_1",
+ name="read",
+ arguments={"path": "docs/pic.png"},
+ )
+ ],
+ [
+ ToolCallPiece(
+ id="call_2",
+ name="show_image",
+ arguments={"path": "docs/pic.png"},
+ )
+ ],
+ ]
+ )
+ fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
+ try:
+ _, _, frames = _stream_chat(client, QUESTION)
+ finally:
+ fastapi_app.dependency_overrides.clear()
+
+ done = frames[-1]
+ assert done["deflected"] is False
+ assert done["answer_image"] == {
+ "source": "docs",
+ "path": "pic.png",
+ "title": "pic",
+ "image_url": f"/api/documents/{doc.id}/image",
+ }
+ # The cited tier includes the same doc (the scripted read).
+ assert ("docs", "pic.png") in [(s["source"], s["path"]) for s in done["sources"]]
+ # The toggle was honoured on the wire: the first (agent) request
+ # offered the 4th tool.
+ offered = scripted.seen_tools[0]
+ assert offered is not None
+ assert [t["function"]["name"] for t in offered] == [
+ "ls",
+ "read",
+ "grep",
+ "show_image",
+ ]
+
+
+def test_toggle_off_same_flow_offers_three_tools_and_no_answer_image_key(
+ client, db, seeded_kb: FakeRagLLM
+) -> None:
+ """The SAME scripted flow with ``answer_images=False`` (the
+ default) → ``show_image`` is NOT in the offered tools (the mock's
+ recorded request carries exactly the three base tools) and the
+ ``done`` frame has NO ``answer_image`` key — the feature's
+ server-side gate is the OFFER (a model that was never offered the
+ tool never calls it, so no such call occurs in the flow)."""
+ _seed_image_doc(db) # resolvable, but the tool is never offered
+ scripted = FakeRagLLM(
+ tool_script=[
+ [
+ ToolCallPiece(
+ id="call_1",
+ name="read",
+ arguments={"path": "docs/pic.png"},
+ )
+ ]
+ ]
+ )
+ fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
+ try:
+ _, _, frames = _stream_chat(client, QUESTION)
+ finally:
+ fastapi_app.dependency_overrides.clear()
+
+ done = frames[-1]
+ assert done["deflected"] is False
+ assert "answer_image" not in done # ABSENT, not null
+ # The offer: exactly the three base tools — no ``show_image``.
+ offered = scripted.seen_tools[0]
+ assert offered is not None
+ assert [t["function"]["name"] for t in offered] == ["ls", "read", "grep"]
+
+
+def test_zero_tool_grounded_turn_done_frame_has_no_answer_image_key(
+ client, db, seeded_kb: FakeRagLLM, answer_images_on: None
+) -> None:
+ """A zero-tool grounded turn (no scripted calls — the toggle is ON,
+ so the offer carries ``show_image``, but the model never calls it)
+ → the ``done`` frame has NO ``answer_image`` key — byte-identical
+ to pre-phase, checked on the raw wire text (not a re-serialized
+ dict)."""
+ scripted = FakeRagLLM() # no tool_script: a zero-tool turn
+ fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
+ try:
+ with client.stream("POST", "/api/chat", json={"message": QUESTION}) as r:
+ assert r.status_code == 200
+ buf = ""
+ raw_frames: list[str] = []
+ for part in r.iter_text():
+ buf += part
+ while "\n\n" in buf:
+ frame, buf = buf.split("\n\n", 1)
+ frame = frame.strip()
+ if frame.startswith("data:"):
+ raw_frames.append(frame)
+ finally:
+ fastapi_app.dependency_overrides.clear()
+
+ done_raw = [f for f in raw_frames if '"type": "done"' in f]
+ assert len(done_raw) == 1
+ # The BYTE check: the key is absent from the wire text itself.
+ assert "answer_image" not in done_raw[0]
+ done = json.loads(done_raw[0].removeprefix("data:").strip())
+ assert done["deflected"] is False
+ assert "answer_image" not in done
+
+
+def test_deflected_turn_done_frame_has_no_answer_image_key_even_toggle_on(
+ client, db, seeded_kb: FakeRagLLM, answer_images_on: None
+) -> None:
+ """A deflected turn runs no agent and never shows an image (the
+ phase's locked design): even with the toggle ON, the ``done``
+ frame has NO ``answer_image`` key — byte-identical to pre-phase."""
+ scripted = FakeRagLLM()
+ fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
+ try:
+ _, _, frames = _stream_chat(client, OFF_TOPIC)
+ finally:
+ fastapi_app.dependency_overrides.clear()
+
+ done = frames[-1]
+ assert done["deflected"] is True
+ assert "answer_image" not in done
+
+
+# ---------------------------------------------------------------------------
+# Phase 124, task 05 — bug fix: a suggested-tier IMAGE doc earns its
+# source chip WITHOUT a ``read`` (its seed block IS its entire content —
+# content == summary == the vision description, phase 122 task 03 — so
+# "suggested" ≡ "read" for it and the phase-119 cite discipline is
+# honored, not weakened). Text docs keep the read-docs-only rule.
+# ---------------------------------------------------------------------------
+
+
+def _seed_image_doc_with_chunk(db) -> Document:
+ """An ``is_image`` documents row WITH a retrievable chunk — the
+ phase-122 pipeline embeds the vision description as the image doc's
+ content (the only embedded text of the document), so the fixture
+ chunk mirrors that: a description tuned to overlap ``QUESTION``
+ (the mock embeddings are token-overlap vectors, as in the E2E
+ mock), which lands the image doc in the SUGGESTED tier (top-5
+ fused rank, no floor — A3) where the auto-cite merge operates.
+ The ``tsv`` column is generated (``to_tsvector`` of the content),
+ so the chunk also hits the FTS half. The fixture's teardown
+ truncates the tables."""
+ doc = _seed_image_doc(db)
+ # The phase-122 invariant for image docs: content == summary == the
+ # vision description (the ONE embedded text of the document — no
+ # pointer line on the summary). The description is tuned to overlap
+ # ``QUESTION`` (the mock embeddings are token-overlap vectors, as in
+ # the E2E mock), which lands the image doc in the SUGGESTED tier
+ # (top-5 fused rank, no floor — A3) where the auto-cite merge
+ # operates.
+ description = "Diagram of how my Kubernetes cluster is set up"
+ doc.content = description
+ doc.summary = description
+ db.add(
+ Chunk(
+ id=uuid.uuid4(),
+ document_id=doc.id,
+ position=0,
+ content=description,
+ embedding=_token_vec(description),
+ )
+ )
+ db.commit()
+ return doc
+
+
+def test_zero_read_grounded_turn_auto_cites_suggested_image_doc(
+ client, db, seeded_kb: FakeRagLLM
+) -> None:
+ """The bug fix pinned end-to-end (TODO L3): a grounded turn with
+ NO scripted tool calls (the mock answers straight from the seed —
+ a real model never ``read``s an image doc: its seed block already
+ carries 100% of its content) whose suggested tier contains an
+ image doc → the done frame's ``sources`` include the image ref
+ WITH ``image_url`` set (the chip + figure data — pre-phase-124 a
+ zero-read turn chipped NOTHING, the phase-122 E2E had to script a
+ ``read`` to earn the chip), and ``related`` does NOT contain the
+ same ``(source, path)`` (the auto-cited doc leaves the "Nearby
+ docs" row — no duplicate figure)."""
+ doc = _seed_image_doc_with_chunk(db)
+ scripted = FakeRagLLM() # no tool_script: a zero-read grounded turn
+ fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
+ try:
+ _, _, frames = _stream_chat(client, QUESTION)
+ finally:
+ fastapi_app.dependency_overrides.clear()
+
+ done = frames[-1]
+ assert done["deflected"] is False
+ # The image doc IS a citation chip now — and its ref (and ONLY its
+ # ref) carries the bytes route the figure renders from.
+ sources = done["sources"]
+ img_refs = [s for s in sources if (s["source"], s["path"]) == ("docs", "pic.png")]
+ assert len(img_refs) == 1 # once — not duplicated by the read merge
+ assert img_refs[0]["image_url"] == f"/api/documents/{doc.id}/image"
+ for ref in sources:
+ if ref is not img_refs[0]:
+ assert "image_url" not in ref # the text refs stay pre-122-shaped
+ # Consequence (a): the related tier already excludes cited-seen
+ # members — the auto-cited image doc is NOT also a "nearby doc".
+ related_keys = {(s["source"], s["path"]) for s in done["related"]}
+ assert ("docs", "pic.png") not in related_keys
+ # Consequence (b): the observability fields are UNCHANGED — the
+ # durable record (query_log.sources) carried the suggested image
+ # doc before the phase (it was suggested, not read); auto-citation
+ # touches the citation tier only, not the record.
+ row = db.scalars(select(QueryLog)).one()
+ assert "docs/pic.png" in row.sources
+ assert "docs/homelab/kubernetes.md" in row.sources
+ # The seed contract held: the HIGH prompt seeded the image doc's
+ # summary — its vision description, the ENTIRE content (image docs
+ # store no ``Source:`` pointer line — the summary mirrors the
+ # description verbatim, phase 122 task 03) — as a ````
+ # block. This is WHY "suggested" ≡ "read" for the doc: a ``read``
+ # would add only the marker line.
+ system = scripted.seen_messages[0][0]["content"]
+ assert ' None:
+ """Text docs are unaffected (consequence c): a zero-read grounded
+ turn on the plain text-only KB (no image docs anywhere in the
+ retrieval) → ``sources`` is ``[]`` — byte-identical to pre-phase
+ (no auto-cite without image docs) and no ``image_url`` key on any
+ ref (the omission rule at the frame level)."""
+ fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
+ try:
+ _, _, frames = _stream_chat(client, QUESTION)
+ finally:
+ fastapi_app.dependency_overrides.clear()
+
+ done = frames[-1]
+ assert done["deflected"] is False
+ assert done["sources"] == [] # phase-119 read-docs-only rule intact
+ for ref in done["sources"] + done["related"]:
+ assert "image_url" not in ref
diff --git a/tests/integration/test_chats_api.py b/tests/integration/test_chats_api.py
index c77ca41..ed98dfe 100644
--- a/tests/integration/test_chats_api.py
+++ b/tests/integration/test_chats_api.py
@@ -899,6 +899,112 @@ def test_shared_serve_includes_the_user_image_path(
assert "image" not in messages[2]
+# ---------------------------------------------------------------------------
+# Phase 124 (task 04): the brain answer's first-class image — the
+# shown document's ref (the LLM's ``show_image`` pick) rides the BRAIN
+# record's ``answer_image`` key (distinct from the user record's
+# phase-123 ``image`` attachment path — the attachment belongs to the
+# question, the answer image to the brain's reply). The phase-113
+# lesson applied up front: the done-time auto-save (``rememberBrainTurn``)
+# carries the key, and the ``extra="forbid"`` boundary silently 422'd
+# any key the model was missing (the A2 quiet-failure path swallowed
+# it — grounded turns silently unsaved). These round-trips are that
+# regression pin.
+# ---------------------------------------------------------------------------
+
+#: The shown image doc's full ref — the done frame's ``answer_image``
+#: shape (source/path/title + the ``image_url`` bytes route).
+ANSWER_IMAGE_REF: dict[str, Any] = {
+ "source": "Homelab",
+ "path": "kubernetes-cluster.png",
+ "title": "Kubernetes Cluster Diagram",
+ "image_url": "/api/documents/0e8a6e5d-7b6c-4f2a-9c1d-3e5f7a9b1c2d/image",
+}
+
+
+def test_create_round_trips_the_brain_answer_image_ref(
+ admin_client: TestClient, db: Session
+) -> None:
+ """The phase-113 regression pin (the A2 quiet-failure path): a
+ brain record carrying ``answer_image`` → ``POST /api/chats`` is
+ 201 (NOT a silent 422) and the served chat round-trips the ref
+ BYTE-FOR-BYTE (the restore path re-renders the answer figure from
+ it). The user record carries NO ``answer_image`` key, and the
+ user's phase-123 ``image`` attachment key stays distinct."""
+ brain = {**FULL_BRAIN, "answer_image": ANSWER_IMAGE_REF}
+ r = admin_client.post(
+ "/api/chats",
+ json={"messages": [_user(FIRST_QUESTION), brain]},
+ )
+ assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
+ body = r.json()
+ assert body["messages"][1]["answer_image"] == ANSWER_IMAGE_REF
+ assert "answer_image" not in body["messages"][0]
+ assert "image" not in body["messages"][1] # the brain's image is a REF
+
+ # The RAW stored JSONB (the DB is the durable boundary): the full
+ # ref, byte-for-byte.
+ raw = db.execute(
+ text("SELECT messages FROM saved_chats WHERE id = :id"),
+ {"id": body["id"]},
+ ).scalar_one()
+ assert raw[1]["answer_image"] == ANSWER_IMAGE_REF
+ assert "answer_image" not in raw[0]
+
+ # The admin GET round-trips the ref losslessly.
+ got = admin_client.get(f"/api/chats/{body['id']}")
+ assert got.status_code == 200
+ assert got.json()["messages"][1]["answer_image"] == ANSWER_IMAGE_REF
+
+
+def test_create_without_answer_image_round_trips_unchanged(
+ admin_client: TestClient,
+) -> None:
+ """A record WITHOUT the key (every pre-phase-124 chat, every
+ image-less turn) round-trips UNCHANGED: the response carries no
+ ``answer_image`` key at all — ABSENT, never ``null`` (the
+ byte-identical-off contract; the phase-50 round-trip contract).
+ ``_expect`` (the stored shape) is unchanged by phase 124 — the
+ omission serializer inherits it on every surface."""
+ brain = {**FULL_BRAIN}
+ r = admin_client.post(
+ "/api/chats",
+ json={"messages": [_user(FIRST_QUESTION), brain]},
+ )
+ assert r.status_code == 201, r.text
+ body = r.json()
+ assert body["messages"] == _expect([_user(FIRST_QUESTION), brain])
+ assert "answer_image" not in body["messages"][0]
+ assert "answer_image" not in body["messages"][1]
+
+ got = admin_client.get(f"/api/chats/{body['id']}")
+ assert got.status_code == 200
+ assert "answer_image" not in got.json()["messages"][1]
+
+
+def test_shared_serve_includes_the_brain_answer_image_ref(
+ admin_client: TestClient,
+) -> None:
+ """The shared view is faithful (task 06's render depends on it):
+ the PUBLIC snapshot's brain record carries the answer ref (the
+ bytes route is public — a shared chat is faithful, the phase-123
+ user-image precedent), and the user record stays WITHOUT the key."""
+ brain = {**FULL_BRAIN, "answer_image": ANSWER_IMAGE_REF}
+ r = admin_client.post(
+ "/api/chats",
+ json={"messages": [_user(FIRST_QUESTION), brain]},
+ )
+ assert r.status_code == 201, r.text
+ share_url = _share(admin_client, r.json()["id"])["share_url"]
+
+ anon = TestClient(fastapi_app) # fresh jar: truly anonymous
+ got = anon.get(f"/api{share_url}")
+ assert got.status_code == 200
+ messages = got.json()["messages"]
+ assert messages[1]["answer_image"] == ANSWER_IMAGE_REF
+ assert "answer_image" not in messages[0]
+
+
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
admin_client: TestClient,
) -> None:
diff --git a/tests/integration/test_docs_api.py b/tests/integration/test_docs_api.py
index 6884976..a350c7c 100644
--- a/tests/integration/test_docs_api.py
+++ b/tests/integration/test_docs_api.py
@@ -1305,24 +1305,30 @@ def test_image_route_404_matrix(admin_client: TestClient, db, tmp_path: Path) ->
_cleanup_kb(db)
-def test_image_route_requires_user_like_the_content_endpoint(
+def test_image_route_is_public_like_the_shared_chat_content(
admin_client: TestClient, db, tmp_path: Path
) -> None:
- """Phase 79 posture (the task's "PUBLIC, like the document content
- endpoint" — the content endpoint has been user-gated since phase
- 79; the ONLY anonymous surface is the shared chats, PLAN A10): an
- anonymous caller gets 401 ``authentication required`` before any
- row is read (a FRESH client — the module's fixture ``client``
- stays unsigned here), a signed-in caller gets the bytes."""
+ """Phase 124 (LOCKED A4 — the answer figure is "visible … on the
+ shared page"): the BYTES route is PUBLIC (the phase-123
+ chat-image precedent — the image is part of a shared chat's
+ rendered content, and the shared chat is the app's ONLY anonymous
+ surface, PLAN A10; the unguessable ``uuid4`` document id IS the
+ credential, no enumeration value): an ANONYMOUS caller (a FRESH
+ client — no session) gets the bytes with the image Content-Type,
+ and a signed-in caller gets the same. The document's TEXT (the
+ content endpoint) stays user-gated — only the bytes are public."""
_cleanup_kb(db)
try:
doc = _seed_image_doc(db, tmp_path)
anonymous = TestClient(fastapi_app)
r = anonymous.get(f"/api/documents/{doc.id}/image")
- assert r.status_code == 401
- assert r.json() == {"detail": "authentication required"}
- # The signed-in client (admin) passes.
- assert admin_client.get(f"/api/documents/{doc.id}/image").status_code == 200
+ assert r.status_code == 200
+ assert r.headers["content-type"].startswith("image/")
+ assert r.content == PNG_1X1
+ # The signed-in client (admin) gets the same bytes.
+ r_admin = admin_client.get(f"/api/documents/{doc.id}/image")
+ assert r_admin.status_code == 200
+ assert r_admin.content == PNG_1X1
finally:
_cleanup_kb(db)
diff --git a/tests/unit/test_image_chips_and_answers.py b/tests/unit/test_image_chips_and_answers.py
new file mode 100644
index 0000000..a415779
--- /dev/null
+++ b/tests/unit/test_image_chips_and_answers.py
@@ -0,0 +1,971 @@
+"""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 ````) 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("") < 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 ``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
diff --git a/tests/unit/test_prompts.py b/tests/unit/test_prompts.py
index 47776cf..c1eeb7c 100644
--- a/tests/unit/test_prompts.py
+++ b/tests/unit/test_prompts.py
@@ -34,6 +34,7 @@ the LOW prompt and ``PERSONA`` / ``TOOLS_SECTION`` stay byte-identical
"""
from __future__ import annotations
+import inspect
import uuid
from datetime import UTC, datetime
@@ -43,6 +44,7 @@ from app.config import Settings
from app.models import Document
from app.rag.prompts import (
PERSONA,
+ SHOW_IMAGE_CLAUSE,
SUGGEST_INTRO,
TOOLS_SECTION,
_base,
@@ -1015,3 +1017,127 @@ def test_plain_text_line_never_leaks_into_high_prompt() -> None:
high = build_high_prompt([doc], notes=notes, kb_overview=kb)
assert PLAIN_TEXT_ONLY_LINE not in high
assert "you have no tools" not in high
+
+
+# ---------- phase 124 (task 03, LOCKED A5): the show_image prompt clause ----------
+
+
+def test_show_image_clause_states_the_four_gate_elements() -> None:
+ """LOCKED A5: the clause states ALL FOUR gate elements, pinned on
+ stable phrases, and references the tool by its backticked name (the
+ ``TOOLS_SECTION`` voice). (1) the image-doc nature — its text is a
+ description GENERATED from the image; (2) the "contains the answer"
+ bar — never a merely related image, never padding; (3) at most one
+ image per answer; (4) the text answer is still provided in full."""
+ assert "`show_image`" in SHOW_IMAGE_CLAUSE # backticked, like the other tools
+ assert "description GENERATED from the image" in SHOW_IMAGE_CLAUSE # (1)
+ assert "what the user actually wants to see" in SHOW_IMAGE_CLAUSE # (2)
+ assert "never for a merely related image" in SHOW_IMAGE_CLAUSE # (2)
+ assert "never to pad an answer" in SHOW_IMAGE_CLAUSE # (2)
+ assert "at most one image per answer" in SHOW_IMAGE_CLAUSE # (3)
+ assert "answer fully in text" in SHOW_IMAGE_CLAUSE # (4)
+ # It is a clause, not a section: no ```` markup of its own
+ # (the ```` mention is plain text, as in the section copy).
+ assert "" not in SHOW_IMAGE_CLAUSE
+ assert "" not in SHOW_IMAGE_CLAUSE
+
+
+def test_high_prompt_default_stays_byte_identical_to_pre_task() -> None:
+ """Completion pin: ``build_high_prompt(docs)`` (no new arg) and the
+ explicit ``show_image=False`` are byte-identical to each other and
+ end with the untouched ``TOOLS_SECTION`` — the pre-task pins above
+ (and the sha pins in :mod:`tests.unit.test_prompt_lock`) pass
+ UNMODIFIED, with or without documents."""
+ doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
+ plain = build_high_prompt([doc])
+ assert build_high_prompt([doc], show_image=False) == plain
+ assert plain.endswith(TOOLS_SECTION)
+ assert SHOW_IMAGE_CLAUSE not in plain
+ empty = build_high_prompt([])
+ assert build_high_prompt([], show_image=False) == empty
+ assert empty.endswith(TOOLS_SECTION)
+ assert SHOW_IMAGE_CLAUSE not in empty
+
+
+def test_high_prompt_show_image_clause_lands_inside_tools_after_the_copy() -> None:
+ """Phase 124 (task 03): ``show_image=True`` builds EXACTLY the
+ toggle-off prompt with its final ```` replaced by the clause
+ (on its own line) + the closing tag — the prefix up to the clause
+ is byte-identical to the toggle-off output (the three-tool copy
+ untouched), the clause lands AFTER the existing copy and BEFORE the
+ closing tag, and appears exactly once."""
+ doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
+ off = build_high_prompt([doc])
+ on = build_high_prompt([doc], show_image=True)
+ # The full structural equation (the clause is the ONLY difference).
+ assert on == off[: -len("")] + SHOW_IMAGE_CLAUSE + "\n"
+ clause_start = on.index(SHOW_IMAGE_CLAUSE)
+ # The prefix up to the clause is byte-identical to the toggle-off
+ # build minus its closing tag.
+ assert on[:clause_start] == off[: -len("")]
+ # From the opener to the clause is TOOLS_SECTION's own text
+ # minus its final — the constant itself is unedited (sha-
+ # pinned in test_prompt_lock) and the three-tool copy is intact.
+ assert on[on.index("") : clause_start] == TOOLS_SECTION[: -len("")]
+ assert "Answer as soon as you have what you need." in on[:clause_start]
+ # The clause (then the closing tag) is the tail of the prompt, after
+ # the existing copy and before the closing tag — exactly once.
+ assert on.endswith(SHOW_IMAGE_CLAUSE + "\n")
+ assert on.index("Answer as soon as you have what you need.") < clause_start
+ assert clause_start < on.index("")
+ assert on.count(SHOW_IMAGE_CLAUSE) == 1
+ assert on.count("") == 1 and on.count("") == 1
+
+
+def test_show_image_clause_rides_only_the_high_prompt() -> None:
+ """The clause appears ONLY in the HIGH prompt: the deflection prompt
+ takes NO ``show_image`` flag at all and its output never carries the
+ clause (byte-identical builds with/without notes/overview — a
+ deflected turn has no tools and never shows an image, the phase's
+ locked design)."""
+ assert "show_image" not in inspect.signature(build_deflect_prompt).parameters
+ for prompt in (
+ build_deflect_prompt(["T1", "T2"]),
+ build_deflect_prompt(["T1"], notes=["be concise"], kb_overview=OVERVIEW),
+ build_deflect_prompt([]),
+ ):
+ assert SHOW_IMAGE_CLAUSE not in prompt
+ assert "`show_image`" not in prompt
+ assert "at most one image per answer" not in prompt
+
+
+def test_high_prompt_empty_documents_show_image_clause_still_in_tools() -> None:
+ """The "(no documents matched…)" body + ``show_image=True``: the
+ clause still lands inside ```` — the grounded turn may still
+ ``ls``/``grep`` its way to an image doc and show it."""
+ off = build_high_prompt([])
+ on = build_high_prompt([], show_image=True)
+ assert on == off[: -len("")] + SHOW_IMAGE_CLAUSE + "\n"
+ assert "(no documents matched — do not invent specifics)" in on
+ assert on.index("") < on.index(SHOW_IMAGE_CLAUSE) < on.index("")
+ assert on.endswith(SHOW_IMAGE_CLAUSE + "\n")
+
+
+def test_show_image_clause_is_last_for_every_high_shape() -> None:
+ """Steering + KB overview + folder lines + documents all present:
+ the section order is UNCHANGED (```` →
+ ```` → ```` → ```` → ````)
+ and the clause is still the last thing the model reads, inside
+ ```` after the three-tool copy."""
+ doc = _doc("deploy/k8s.md", "CONTENT_SENTINEL", "K8s Deploy", summary="K8s deploy notes.")
+ lines = ["Homelab/deploy/: k8s.md (1 doc)"]
+ off = build_high_prompt([doc], notes=["be concise"], kb_overview=OVERVIEW, folder_lines=lines)
+ on = build_high_prompt(
+ [doc],
+ notes=["be concise"],
+ kb_overview=OVERVIEW,
+ folder_lines=lines,
+ show_image=True,
+ )
+ assert on == off[: -len("")] + SHOW_IMAGE_CLAUSE + "\n"
+ clause_start = on.index(SHOW_IMAGE_CLAUSE)
+ assert on[on.index("") : clause_start] == TOOLS_SECTION[: -len("")]
+ for section in ("", "", "", ""):
+ assert on.index(section) < on.index("")
+ assert on.index("K8s deploy notes.") < on.index("") # the summary still seeds
+ assert SHOW_IMAGE_CLAUSE not in off # the toggle-off twin carries none of it