chore(agent): phase 124 from TODO.md — image source chips + LLM answer images

Convert the single remaining TODO item (TODO.md L3) into phase
124_image_chips_and_answers (Protocol B append; numbered after 123).

Two workstreams in one phase:
- Bug: retrieved image docs never earn a source chip because the
  phase-119 cited tier is read-docs-only and a model never reads an
  image doc (its seed block already holds its entire content). Fix by
  auto-citing suggested-tier image docs.
- Feature: new toggle-gated show_image agent tool (BOR_ANSWER_IMAGES,
  default off) so the LLM can surface a supporting image in the answer;
  relevance gate is the model's decision; one image per answer; text
  answer always provided; done-frame answer_image + saved/shared
  persistence + the answer figure.

7 tasks: 01 toggle, 02 tool, 03 prompt clause, 04 frame+persistence,
05 auto-cite bug fix, 06 UI answer figure, 07 mock trigger + E2E.
TODO.md cleared to a bare title (items now live in .agents/phases/).

LLM-Generated: true
This commit is contained in:
2026-09-26 12:47:52 -04:00
parent a6293f739b
commit 7bd6335929
8 changed files with 253 additions and 0 deletions
@@ -0,0 +1,56 @@
# Phase 124 — Image chips and answers: retrieved images earn their source chips, and the LLM can show a supporting image in the answer
**Source:** `TODO.md` L3 — "Image support is working but images don't show up as sources in the sources chips. I also want the chat to be able to show images as answers, so if there's a supporting image with the answer rather than summarizing it alone the supporting image should be shown in the chat if the model thinks it would be effective. Note this will require some relevancy tuning since we don't want to just show every image that seems relevant. The LLM will need to decide when to show an image based on whether it contains the answer. Summaries should still be provided, and showing images as answers in chat should be a feature toggle in .env."
**Story:** n/a (feature request + bug report; completes the phase-122 image capability on the chat side).
**Context:** Phase 122 (`122_image_documents`, complete) — the image-doc pipeline: `BOR_IMAGES` toggle (`app/config.py` L379), `Document.is_image`/`image_path` (L153), the vision description as content+summary, `GET /api/documents/{id}/image` bytes route (`app/api/docs.py` L247), the ext→mime map, and `source_ref_with_image` (`app/rag/retriever.py` L945 — the shared ref builder that already puts `image_url` on image docs' cited + related refs). Phase 119 (complete) — the cited tier is the agent's READ DOCS only (`app/api/chat.py` L1040-1046, the deduped `holder.read_docs`). Phase 118 — the `<documents>` seed blocks carry the top-N suggested docs' SUMMARIES (`app/rag/prompts.py` `build_high_prompt`, `SUGGEST_INTRO`); for an image doc the summary IS the content (the vision description) — a seeded image doc is already 100% in the prompt. The agent surface: `AGENT_TOOLS` (the harness-aligned `ls`/`read`/`grep`, `app/rag/agent.py` L264), `_execute_tool` (L1193 — the read branch L1257-1328: `_resolve_path` L1037, the phase-72 `_no_document_refusal` L1098, the `IMAGE_DOC_MARKER` L438, the `Document …`/`date:` result header contract), `AgentHolder` (L1144 — `read_docs`/`tool_calls`/`read_truncations`), `run_agent` (L~1393 — the tools wiring L1490: `tools = AGENT_TOOLS if max_rounds > 0 else None`). The frames: `ChatDoneEvent` (`app/schemas.py` L236 — `deflected`/`sources`/`related`/`suggestions`; `SourceRef` L124 with the None-`image_url`-omitting serializer L159-164), the done-frame build in `app/api/chat.py` L1119-1133. Persistence: `ChatMessage` (L892, `extra="forbid"` — the brain-record meta keys, `sources`/`related` capped lists, the phase-113 lesson that a done-time auto-save key missing from the model 422s silently). Frontend: `frontend/assets/app.js` — the `done` handler (L2981+: `appendSources(wrap, ev.sources)` L2999, the `rememberBrainTurn` save point L3015+), `renderStoredMessage` (L1856 — the brain branch's `appendSources(wrap, m.sources)` L1904), `appendSourceImageFigure` (L1677 — the phase-122 chip figure, the collapse-on-error + `fetchContentSummary` alt pattern to reuse, L1716), `addMessage` (L948). `frontend/assets/shared.js` — the shared page's fragment builders (the brain branch L354+, `addSources` L274 — the phase-123 user-image renderer L329+ is the closest precedent). `frontend/assets/styles.css` — `.source-image` family (L784-815) the answer-figure styling extends. E2E tooling: `tests/e2e/mock_llm.py` (the marker-driven mock — `IMAGE_DESCRIPTION_MODE` L~33, the `use your tools` scripted-`read` trigger L~100, the `<documents>` block tail-echo parser), `tests/e2e/test_image_documents.py` (the module-apps pattern with `BOR_IMAGES` on/off L88-186, the fixture archive L201+, the upload/sync helpers L237+, and — the diagnosis pin — the comment at L62-64: a zero-read grounded turn chips NOTHING, so the suite had to script a mock `read` to earn the image doc's chip).
## Objective
Two gaps in the phase-122/123 image capability, closed in one phase: (1) the **bug** — a retrieved image document effectively never shows up as a source chip, because the cited tier is read-docs-only and a real model never `read`s an image doc (its seed block already carries its entire content); fix it deterministically by auto-citing suggested-tier image docs. (2) the **feature** — the LLM can show a standalone image document as part of its answer: a new `show_image` tool it calls only when the image itself contains the answer (the relevance gate is the model's decision, taught in the tool description + prompt clause — never every merely-relevant image), the text answer/summary is still provided in full, one image per answer, all behind a new `BOR_ANSWER_IMAGES` `.env` toggle that defaults to false.
## Dependencies
- `123_chat_image_questions` (complete) — pipeline predecessor (execution order) and code dependency: the `BOR_IMAGES`/image-extension/mime conventions (this phase's toggle and tool follow them), `Document.is_image`, the image bytes route, and `source_ref_with_image` (the answer-image ref is built by it).
- Code dependencies (all complete): phase 118 summary seeding, phase 119 read-docs citation tier, phase 37/70 the agent tool loop, phase 113 the brain-record meta persistence, phase 51 shared chats, phase 122 the image-doc pipeline.
## Design (shared by all tasks — the executor reads this, not the chat)
- **Root cause (task 05, the bug):** since phase 119 (LOCKED A1) `done.sources` = the agent's `holder.read_docs` deduped by `(source, path)` — a zero-read grounded turn chips nothing. For an image document the seed block (`SUGGEST_INTRO` + `<document>` block) carries its summary — which IS its entire content (the vision description; content == summary by construction, phase 122 task 03). Reading it would add only the marker line. A real model therefore never reads it → the chip never appears (the phase-122 E2E had to script a mock `read` — `test_image_documents.py` L62-64 — the live-app confirmation the owner saw). The fix is deterministic and server-side: **an image doc in the suggested (seed) tier is auto-cited** — in `app/api/chat.py`'s `cited_docs` build (L1040-1046), after the read-docs loop, merge the `is_image` members of `plan.suggested_docs` into `cited_docs` (deduped against the read docs via `cited_seen`, suggestion order; grounded turns only — a deflected turn cites nothing, unchanged). Rationale: for an image doc "suggested" ≡ "read" — the model already has 100% of its content, so the phase-119 cite discipline ("cite the document you answered from") is honored, not weakened. Consequences: auto-cited image docs leave the related tier (`related_refs` already excludes `cited_seen` members, L1123-1127 — no duplicate figure); the `query_log`/log-line `sources=` field is untouched (it logs `record_docs`, which already includes suggested docs). An image doc ranking beyond the suggested set is unchanged — it stays in the "Nearby docs" row, where its figure already renders (phase 122 task 05).
- **Toggle (task 01, LOCKED A2):** one new `Settings` field — `answer_images: bool = False` (`BOR_ANSWER_IMAGES`, the phase-67 `llm_retries`/phase-122 `images` bool style; `1`/`true` on). A SEPARATE knob from `images` (`BOR_IMAGES`): indexing image docs and showing them in answers are independent owner decisions — `BOR_ANSWER_IMAGES=true` with `BOR_IMAGES=false` is a coherent (if image-less) state: the tool is offered, finds no image docs, and always refuses. `.env.example` documents it beside the existing image block (L124-136). Off = byte-identical everywhere: no 4th tool offered, no prompt clause, no `answer_image` key in any frame or record.
- **The tool (task 02, LOCKED A3/A5):** a 4th agent tool `show_image(path)` — same harness shape as the other three (one string argument, the combined `source/path` identity). It is OFFERED only when `settings.answer_images` is true: `run_agent`'s tools wiring (L1490) becomes `tools = agent_tools(settings) if max_rounds > 0 else None`, where `agent_tools` returns the three base tools + `show_image` when the toggle is on (the base three stay a byte-identical list — the E2E mock does not inspect the tools array, but the offer gating is the feature's server-side gate). The description carries 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 — a diagram, screenshot, or photo of the thing the user asked about — never for mere topical relevance; at most ONE image per answer; the text answer is still given in full (the image supplements, never replaces — "summaries should still be provided"). Handler (a `call.name == "show_image"` branch in `_execute_tool`, before the unknown-tool fallback): missing/empty `path` → the `MISSING_SHOW_ARGS` refusal (the `MISSING_READ_ARGS` shape); unresolvable arg → the phase-72 `_no_document_refusal` teaching (same as read); a resolvable NON-image doc → the `NOT_AN_IMAGE_DOC` refusal ("not an image document — only standalone image documents can be shown" — a text doc's `show_image` is refused, not silently converted to a read); a second call in the same turn → the `ALREADY_SHOWING` refusal (A3 — one image per answer, enforced server-side, not just taught). Refusals count in nothing and consume a round, exactly like every other refusal. Success: `holder.tool_calls += 1`, the doc appended to the NEW `AgentHolder.shown_images: list[Document]` field (the API layer's read-after-stream signal, the `read_docs` precedent), and a result that CONFIRMS the show and carries the doc block so the model verifies before finalizing — a first line `Image shown to the user — it is now part of your answer.`, then the read-result document block for the image doc (`Document {source}/{path}:` header + `date:` line + `IMAGE_DOC_MARKER` + the description — an image doc's content is the summary-capped description, always short, so no truncation machinery; the mock's `_READ_RESULT_PREFIX` header contract is unaffected because that contract is pinned on `read` results).
- **Prompt (task 03, LOCKED A5):** the `TOOLS_SECTION` constant stays BYTE-IDENTICAL; `build_high_prompt` gains the clause only when its caller passes the toggle on — a new `show_image: bool = False` parameter (default keeps every existing call byte-identical; `app/api/chat.py` passes `settings.answer_images`). The clause is appended INSIDE the `<tools>` section (after the existing three-tool copy, before `</tools>`), so the E2E mock's `<tools>`-presence keying is untouched and the clause rides the grounded HIGH prompt only — the deflection prompt (no tools, ever) is unchanged. The clause teaches the same gate as the tool description, plus the image-doc nature: the text of an image doc in a `<documents>` block or a `read` result is a description GENERATED from the image; show the image when THAT is what the user actually wants to see (the thing they asked about, in picture form); one image per answer; answer fully in text either way.
- **Frame + persistence (task 04):** `ChatDoneEvent` gains `answer_image: SourceRef | None = None` — the shown doc's ref, built by the SAME `source_ref_with_image` (source/path/title + `image_url`). A `model_serializer(mode="wrap")` on `ChatDoneEvent` DROPS `answer_image` from the dumped dict when it is `None` (the `SourceRef._serialize` None-omission precedent) — without it, `model_dump()` would add `"answer_image": null` to every turn's frame and break the byte-identical-off contract. `ChatMessage` gains `answer_image: SourceRef | None = None` (brain records only — the user record's phase-123 `image` key, the stored PATH string, is untouched and distinct). This is the phase-113 lesson applied up front: the done-time auto-save carries the key, and a `extra="forbid"` model missing it would 422 every grounded brain record silently (the A2 quiet-failure path) — the integration suite pins the round-trip. `app/api/chat.py`'s done-frame build (L1119-1133) passes `answer_image=source_ref_with_image(holder.shown_images[0]) if holder.shown_images else None`.
- **UI (task 06):** the `done` handler (app.js L2981+) renders `ev.answer_image` as a prominent ANSWER FIGURE in the brain bubble's `.msg-body` — appended right BEFORE `appendSources` so it sits below the answer text, above the source-chip row (a new `appendAnswerImage(wrap, ref)` helper, distinct from the compact chip figure `appendSourceImageFigure` — the answer image is a first-class part of the answer, styled larger). The figure: `<figure class="answer-image">` with a lazy `<img>` (`loading="lazy"`, `src = ref.image_url`, the public bytes route), `alt` = `ref.title` immediately and swapped to the doc's summary via the SAME `fetchContentSummary` pattern (L1716 — the phase-122 WCAG alt contract); an `img` error REMOVES the figure (the collapse-to-nothing rule — an answer with a lost image renders as a normal answer, never a broken icon; the chip row below still cites the doc). The figure reuses the chip navigation affordance (the `documentUrl` href as the no-JS escape hatch; left-click → the same-page document modal, phase 26) — it is a link to the image document. Persistence: the `rememberBrainTurn` meta gains `answer_image: ev.answer_image || undefined` (the house undefined-drops-the-key pattern — pre-phase and image-less turns store byte-identical records). Restore: `renderStoredMessage`'s brain branch renders `m.answer_image` through the SAME helper before `appendSources(wrap, m.sources)` (L1904) — the refresh lands pixel-identical. Shared page: `shared.js`'s brain fragment builder renders it the same way (the bytes route is public — a shared chat is faithful, the phase-123 user-image precedent). CSS: `.answer-image` / `.answer-image-img` in the `.source-image` family's language (`.source-image-img` L793: the bordered-image treatment) but larger — the answer figure is not a chip accessory: max-width 100% of the bubble, a capped max-height (≈320px), `object-fit: contain`, theme-aware border/background, a caption line (the summary, the `.source-image-caption` styling) — the theme's image treatment, WCAG contrast per PLAN §7.
- **NOT touched:** the image-INGEST pipeline (phase 122), question-image attachment (phase 123), the suggestion/related TIERING math (only the cited-tier composition changes), the honesty gate, the deflection path (no tools, no `answer_image` — a deflected answer never shows an image), history trimming (an `answer_image` rides the stored record, never the model's history — the model's image input remains the phase-123 turn-local contract), and the agent round cap.
- **Locked assumptions:** **A1** — auto-cite scope = suggested-tier image docs (deeper-ranked ones keep the related-row figure). **A2** — `BOR_ANSWER_IMAGES`, default false, independent of `BOR_IMAGES`. **A3** — one image per answer (server-enforced refusal). **A4** — the answer figure renders in the brain bubble below the text, above the chips; visible on restore and the shared page. **A5** — the relevance gate is the LLM's decision (tool description + prompt clause), no server-side image scoring.
## Tasks
1. `01_answer_images_toggle.md` — `Settings.answer_images` (`BOR_ANSWER_IMAGES`, default false) + `.env.example` doc; off = byte-identical.
2. `02_show_image_tool.md` — `agent_tools` gating + the `show_image` handler (refusals, `holder.shown_images`, the confirming result).
3. `03_prompt_clause.md` — the toggle-gated `show_image` clause in the HIGH prompt's `<tools>` section (base byte-identical).
4. `04_frame_and_persistence.md` — `ChatDoneEvent.answer_image` (None-omitting serializer) + `ChatMessage.answer_image` + the chat.py done-frame wiring.
5. `05_auto_cite_image_docs.md` — the bug fix: suggested-tier image docs merge into `cited_docs`.
6. `06_chat_ui_answer_image.md` — the answer figure: live `done` render, persistence, restore, shared page, CSS.
7. `07_answer_image_tests.md` — the mock-LLM `show_image` trigger + isolated E2E `test_answer_images.py` + the final coverage gate.
## Testing & Quality
- Unit: `tests/unit/test_image_chips_and_answers.py` (new, across tasks 01-05) — settings parsing (default false, truthy parsing, independent of `images`); `agent_tools` (off → the three base tools byte-identical; on → + `show_image`); the `show_image` handler (success → `shown_images` appended + `tool_calls` bumped + the confirming result containing the doc block; each refusal: missing args, no-doc teaching, non-image doc, already-showing — none counted); the prompt clause (off → `build_high_prompt` byte-identical to pre-phase; on → the clause inside `<tools>`, base copy byte-identical; the deflection prompt unchanged either way); the `ChatDoneEvent` serializer (`answer_image=None` → key ABSENT from the dump, not null; present → the full ref incl. `image_url`); the auto-cite merge (a suggested image doc + read text docs → cited = reads first then the image doc, deduped; a suggested image doc the agent also read → once; deflected → `[]`; a non-image suggested doc → never auto-cited).
- Integration: `tests/integration/test_chat_api.py` (extend, task 04) + `tests/integration/test_chats_api.py` (extend, task 04) — a grounded turn whose mock client emits a scripted `show_image` tool call on an image doc → the done frame carries `answer_image` (source/path/title/`image_url`) and the cited tier includes the doc; a toggle-off turn (mock emits the same call — the tool is not offered, so in the mock-driven integration test the frame simply has no `answer_image` and no such call occurs) → the frame dump has NO `answer_image` key and the offer list is the three base tools; the auto-cite integration: a zero-read grounded turn (no tool calls at all) whose suggested tier contains an image doc → the done frame's `sources` include the image ref WITH `image_url` (the bug-fix pin end-to-end) and the related tier excludes it; a saved-chat round-trip: a brain record with `answer_image` persists and serves back (the phase-113 422-silence regression pinned).
- E2E: `tests/e2e/test_answer_images.py` (new, task 07) — isolated run per AGENTS.md §4; module apps on the `test_image_documents.py` pattern: `BOR_IMAGES=true` + `BOR_ANSWER_IMAGES=true` (the feature) and a second app with `BOR_IMAGES=true` + toggle unset (the default-off contract). Feature flow: upload the fixture image archive → sync → ask the mock's trigger question → the mock scripts ONLY a `show_image` call (NO `read` — the zero-read turn) → the brain bubble shows the ANSWER FIGURE (an `<img>` whose src is the doc's bytes route, visible, not collapsed) AND the source-chip row carries the image doc's chip + figure (the bug fix — a zero-read turn chips it) → reload → the restored bubble shows the figure again → the shared link shows it. Default-off flow: the same question → no answer figure in the DOM, the done frame (via the saved record) carries no `answer_image`, and the mock never sees the tool (assert the recorded request's `tools` has 3 entries).
- Coverage: **>90%** on `app/` (validate.sh gate); `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] With `BOR_IMAGES=true` (any `BOR_ANSWER_IMAGES`): a retrieved image doc in the suggested tier appears in the sources chips WITH its figure on a zero-read grounded turn — no scripted `read` needed (the phase-122 E2E's scripted `read` remains valid but is no longer load-bearing for the chip).
- [ ] With `BOR_IMAGES=true` + `BOR_ANSWER_IMAGES=true`: the LLM can call `show_image` on an image doc; the answer figure renders in the bubble (below the text, above the chips) with the summary as alt/caption; the answer text is still streamed in full; a second `show_image` call is refused; the record persists the figure and restore + the shared page render it.
- [ ] With `BOR_ANSWER_IMAGES` unset (the default): no `show_image` tool offered, no prompt clause, no `answer_image` key in any done frame or saved record — every byte-identical contract of completed phases holds.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/` by the pipeline gate.
## Locked decisions
- **A1 — auto-cite scope: suggested-tier (seeded) image docs become citation chips; image docs ranked beyond the suggested set stay in the "Nearby docs" row (their figure already renders there) (owner-confirmed 2026-09-26, roadmap confirmation).**
- **A2 — `BOR_ANSWER_IMAGES`, default false, a SEPARATE toggle from `BOR_IMAGES` (indexing) — showing answer images requires image docs to exist but the knobs stay independent (owner-confirmed 2026-09-26).**
- **A3 — one image per answer: a second `show_image` call in a turn is refused server-side (owner-confirmed 2026-09-26).**
- **A4 — the answer figure renders in the brain bubble below the answer text, above the source chips; lazy-loaded; collapses on load failure (never a broken icon); alt = the doc's summary; visible on refresh restore and the shared page (owner-confirmed 2026-09-26).**
- **A5 — the relevance gate is the LLM's decision, taught in the tool description + the prompt clause (show only when the image itself contains the answer — a diagram/screenshot/photo of the thing asked about — never mere topical relevance; the text answer is always provided in full); no server-side image scoring (owner-confirmed 2026-09-26).**
## Commit
```bash
git add app/ frontend/ tests/ .env.example .agents/phases/ && git commit --no-gpg-sign -m "feat(chat): image source chips + LLM-chosen answer images behind BOR_ANSWER_IMAGES"
```
@@ -0,0 +1,21 @@
# Task 01 — The `BOR_ANSWER_IMAGES` feature toggle
**Phase:** `124_image_chips_and_answers` · **Source:** `TODO.md:3` — "…and showing images as answers in chat should be a feature toggle in .env."
**Story:** n/a
## Objective
A new `Settings.answer_images` field (`BOR_ANSWER_IMAGES`, default **false**) — the single knob that turns the whole answer-image feature on or off (task 02's tool offer, task 03's prompt clause, task 04's frame field all key on it). Off (the default) = byte-identical behavior everywhere.
## Work
1. `app/config.py` — add `answer_images: bool = False` to `Settings` inside the existing image block (after `chat_image_max_mb`, L413 — the phase-122 `images` field L379 is the style precedent: a docstring stating it gates the ANSWER side only, that it is INDEPENDENT of `images`/`BOR_IMAGES` (indexing image docs vs showing them — A2), and that off = no `show_image` tool, no prompt clause, no `answer_image` frame key). `BOR_`-prefixed, so `BOR_ANSWER_IMAGES=1`/`true` enables it.
2. `.env.example` — document it beside the existing image block (L124-136): a commented `# BOR_ANSWER_IMAGES=0` line + a two-line comment (off by default; enable alongside `BOR_IMAGES` to let the model show a supporting image in its answer).
3. ASSUMPTION: none beyond the phase's locked A2 (name, default, independence — owner-confirmed 2026-09-26). Do NOT add an `answer_images` key to `GET /api/config` — the UI is purely reactive (it renders what the done frame carries); there is no client-side affordance to gate, so the phase-122 config-flag precedent does not apply here.
## Testing & Quality
- Unit: `tests/unit/test_config.py` (extend) or the new `tests/unit/test_image_chips_and_answers.py` (create it here if absent) — `answer_images` defaults to `False`; `BOR_ANSWER_IMAGES=1` / `=true` (the settings loader's accepted truthy forms — mirror the existing `images`/`llm_retries` bool test) parse to `True`; `=0`/`=false`/unset → `False`; setting it does not affect `images` and vice versa.
- Coverage: **>90%** on this task's new/modified code (a config field — the parsing tests carry it).
## Completion Criteria
- [ ] `Settings().answer_images is False` with no env; `BOR_ANSWER_IMAGES=1` yields `True`.
- [ ] `.env.example` documents the new var with the "off by default" comment.
- [ ] Full test suite green; no behavior change in completed work (no other file reads the field yet).
@@ -0,0 +1,30 @@
# Task 02 — The `show_image` agent tool (toggle-gated)
**Phase:** `124_image_chips_and_answers` · **Source:** `TODO.md:3` — "The LLM will need to decide when to show an image based on whether it contains the answer." + "…we don't want to just show every image that seems relevant." + "Summaries should still be provided"
**Story:** n/a
## Objective
The 4th agent tool `show_image(path)` — offered ONLY when `settings.answer_images` is true — by which the LLM decides to show a standalone image document as part of its answer. The tool's description IS the relevance gate (A5); the handler enforces the contract server-side (image docs only, one per answer) and records the shown doc in `holder.shown_images` for task 04's frame.
## Work
1. `app/rag/agent.py` — the tool definition: keep `AGENT_TOOLS` (L264) as the three base tools, UNCHANGED (byte-identical — existing tests pin its shape); add a new module constant `SHOW_IMAGE_TOOL: dict[str, Any]` (the same OpenAI function shape: `name: "show_image"`, one required string arg `path`) and a function `agent_tools(settings: Settings) -> list[dict[str, Any]]` returning `[...AGENT_TOOLS]` + `SHOW_IMAGE_TOOL` when `settings.answer_images` else `[...AGENT_TOOLS]` (a fresh list each call — never mutate the constant).
- The description carries the full gate (A5) in the other tools' voice: "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 `<documents>` blocks — a bare document path will not resolve. Call one tool at a time — wait for this result before your next call."
- The `path` parameter description mirrors `read`'s (the combined `source/path` contract, the bare-path-will-not-resolve teaching).
2. `app/rag/agent.py` — the offer gating: `run_agent`'s wiring (L1490) changes from `tools: list[dict[str, Any]] | None = AGENT_TOOLS if max_rounds > 0 else None` to `tools = agent_tools(settings) if max_rounds > 0 else None` (the `max_rounds == 0` kill switch still yields `tools=None`, unchanged; `settings` is already a `run_agent` parameter).
3. `app/rag/agent.py` — the handler: a `if call.name == "show_image":` branch in `_execute_tool` (L1193), placed after the `read` branch and before the unknown-tool fallback (L1388):
- `raw_path = call.arguments.get("path")`; stripped-arg empty → return `MISSING_SHOW_ARGS` (new constant, the `MISSING_READ_ARGS` L388 shape: "show_image requires a string argument 'path'.").
- `doc, _source, _path = _resolve_path(db, arg)` (L1037); `doc is None` → `return _no_document_refusal(db, arg)` (the phase-72 "did you mean …?" teaching — same as read).
- `not doc.is_image` → `return NOT_AN_IMAGE_DOC` (new constant: "`{arg}` is not an image document — only standalone image documents can be shown as answers." with the arg echo — a text doc is refused, never converted to a read).
- `holder.shown_images` non-empty → `return ALREADY_SHOWING` (new constant, the `ALREADY_IN_CONTEXT` L384 shape: "Already showing an image this answer — do not show another one. Answer from what you have." — A3, server-enforced).
- Success: `holder.shown_images.append(doc)`; `holder.tool_calls += 1`; return the confirming result — a FIRST LINE `Image shown to the user — it is now part of your answer.` followed by the same document block `read` returns for an image doc at-or-under-cap (L1323-1328): `Document {doc.source}/{doc.path}:\n` + `date: {doc.created_at:%Y-%m-%d}\n` + `IMAGE_DOC_MARKER` + `\n`-handling as there + `{doc.content}` (an image doc's content is the summary-capped vision description — always under `read_max_chars`, so NO truncation machinery; if the content ever exceeded the cap, the plain uncapped block is still correct for this purpose — the description is short by construction, phase 122 task 03). The confirming line is NEW (not in `read`'s result — the model must know the show registered); the `Document …` header contract (the E2E mock's `_READ_RESULT_PREFIX`) is pinned on `read` results and this block deliberately reuses its exact shape.
4. `app/rag/agent.py` — `AgentHolder` (L1144): add `shown_images: list[Document] = field(default_factory=list)` with a docstring entry (the shown docs, in call order — at most one in practice due to the `ALREADY_SHOWING` refusal; read by `app.api.chat` after the stream for the `answer_image` frame, the `read_docs` precedent).
5. ASSUMPTION: none beyond the phase's locked A3/A5 (owner-confirmed 2026-09-26). The per-turn log line (PLAN §9) needs no new field: a successful `show_image` is a tool call (`tool_calls=N` already counts it); the `answer_image` frame is task 04's job.
## Testing & Quality
- Unit (new `tests/unit/test_image_chips_and_answers.py` if task 01 didn't create it, else extend): `agent_tools` — toggle off → exactly the three base tools, equal to `AGENT_TOOLS` element-for-element (and NOT the same list object mutated); toggle on → the fourth tool present with name `show_image`, required `["path"]`, description containing the gate copy ("at most one image", "never for a merely related"); the handler matrix via `_execute_tool` with a temp-DB fixture and a mock `ToolCallPiece` (the existing agent unit-test fixtures): (a) empty/missing `path` → `MISSING_SHOW_ARGS`, `tool_calls` unchanged, `shown_images` empty; (b) unresolvable arg → the `_no_document_refusal` text, nothing counted; (c) a resolved TEXT doc → `NOT_AN_IMAGE_DOC`, nothing counted; (d) a resolved image doc → `shown_images == [doc]`, `tool_calls == 1`, result starts with the confirming line then the `Document {source}/{path}:` header + `date:` line + `IMAGE_DOC_MARKER` + the description; (e) a second call after a success → `ALREADY_SHOWING`, `shown_images` still length 1.
- Coverage: **>90%** on this task's new/modified code (the handler matrix + `agent_tools` tests carry it).
## Completion Criteria
- [ ] With `answer_images=False`: `agent_tools(settings)` returns exactly the three base tools and a grounded turn offers no `show_image` (the `run_agent` wiring passes the gated list).
- [ ] With `answer_images=True`: a scripted `show_image` call on an image doc appends to `holder.shown_images`, bumps `tool_calls`, and returns the confirming result with the doc block; every refusal path counts in nothing and leaves `shown_images` empty/unchanged.
- [ ] Full test suite green; `AGENT_TOOLS` itself byte-identical (no existing test diffs); no behavior change in completed work.
@@ -0,0 +1,25 @@
# Task 03 — The `show_image` prompt clause (HIGH prompt, toggle-gated)
**Phase:** `124_image_chips_and_answers` · **Source:** `TODO.md:3` — "Note this will require some relevancy tuning since we don't want to just show every image that seems relevant. The LLM will need to decide when to show an image based on whether it contains the answer."
**Story:** n/a
## Objective
When the toggle is on, the grounded HIGH prompt teaches the model the `show_image` contract — the relevance gate, the image-doc nature (its text is a GENERATED description), the one-image cap, and "summaries still provided" — as a clause appended inside the locked `<tools>` section. The `TOOLS_SECTION` constant and the whole prompt stay BYTE-IDENTICAL when the toggle is off (every existing `tests/unit/test_prompts.py` pin holds untouched).
## Work
1. `app/rag/prompts.py` — add a new module constant `SHOW_IMAGE_CLAUSE: str` (the appended copy — written in the `TOOLS_SECTION` voice, referencing the tools by backtick names):
"`show_image` displays a standalone image document as part of your answer. The text of an image document — in a <documents> 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."
2. `app/rag/prompts.py` — `build_high_prompt` (L~437) gains a keyword parameter `show_image: bool = False` (the default keeps EVERY existing call byte-identical — update the docstring: section order unchanged, the clause rides inside `<tools>`). Implementation: the final return currently concatenates `… + "</documents>\n" + TOOLS_SECTION` — with `show_image=False` keep EXACTLY that; with `show_image=True` build the tools section as `TOOLS_SECTION`'s body + the clause: i.e. replace the trailing `TOOLS_SECTION` with the same text minus its final `</tools>`, then the clause, then `</tools>` (the clause lands AFTER the existing three-tool copy and BEFORE the closing tag — do NOT edit `TOOLS_SECTION` itself).
3. `app/api/chat.py` — the `build_high_prompt` call site (find it where `plan.system_prompt` is assembled for the grounded plan — the retrieval-plan construction; it is the single HIGH-prompt build site) passes `show_image=settings.answer_images`. No other call site changes (tests' direct calls keep the default).
4. The deflection prompt (`build_deflect_prompt`) is UNTOUCHED — a deflected turn has no tools and never shows an image (the phase's locked design).
5. ASSUMPTION: none beyond the phase's locked A5 (owner-confirmed 2026-09-26). The clause copy above is the starting text — it must state all four gate elements (image-doc nature / "contains the answer" bar / one per answer / text answer in full); exact wording is the executor's within that constraint (the E2E mock keys on `<tools>` PRESENCE, not wording — `tests/e2e/mock_llm.py`'s documented contract — so a re-wording is safe, but the four elements are not).
## Testing & Quality
- Unit: `tests/unit/test_prompts.py` (extend) — (a) `build_high_prompt(docs)` (no new arg) → byte-identical to the pre-task output (the existing pins already assert the `TOOLS_SECTION` tail — they must pass UNMODIFIED); (b) `build_high_prompt(docs, show_image=True)` → the output ends with the clause then `</tools>`, the clause contains the four gate elements (assert on stable phrases: "description GENERATED from the image", "at most one image per answer", "answer fully in text"), and the prefix up to the clause is byte-identical to the toggle-off output (the three-tool copy untouched); (c) the clause appears ONLY in the HIGH prompt — `build_deflect_prompt` output is byte-identical with and without the flag's existence (it takes no such flag); (d) with empty `documents` (the "(no documents matched…)" body) + `show_image=True` → the clause still lands inside `<tools>` (the grounded turn may still `ls`/`grep` its way to an image doc and show it).
- Coverage: **>90%** on this task's new/modified code (the prompt tests carry it — pure string logic).
## Completion Criteria
- [ ] `build_high_prompt(..., show_image=False)` (and all pre-existing calls) → byte-identical output (existing `test_prompts.py` pins pass unmodified).
- [ ] `show_image=True` → the clause is inside `<tools>`, after the existing copy, with all four gate elements; the deflection prompt is unchanged.
- [ ] The grounded plan carries the clause exactly when `settings.answer_images` is true.
- [ ] Full test suite green; no behavior change in completed work.
@@ -0,0 +1,23 @@
# Task 04 — The `answer_image` done frame + saved-chat persistence
**Phase:** `124_image_chips_and_answers` · **Source:** `TODO.md:3` — "…the supporting image should be shown in the chat if the model thinks it would be effective."
**Story:** n/a
## Objective
The shown image reaches the client and survives a refresh: `ChatDoneEvent.answer_image` (the shown doc's `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, restored by task 06).
## Work
1. `app/schemas.py` — `ChatDoneEvent` (L236): add `answer_image: SourceRef | None = None` with a docstring entry (the shown image document — the LLM's `show_image` pick, phase 124; the brain answer's first-class image, distinct from the user record's question attachment `ChatMessage.image`). Add a `model_serializer(mode="wrap")` to `ChatDoneEvent` (the `SourceRef._serialize` L159-164 precedent): `data = handler(self)`; `if self.answer_image is None: data.pop("answer_image", None)`; return `data` — WITHOUT this, `model_dump()` emits `"answer_image": null` on every frame and the byte-identical-off contract (task 01) is broken.
2. `app/schemas.py` — `ChatMessage` (L892, `extra="forbid"`): add `answer_image: SourceRef | None = None` (on BRAIN records only — the user record's phase-123 `image: str | None` stored-path field is untouched and semantically distinct; the docstring must say so). This is the phase-113 lesson applied up front: the done-time auto-save (task 06's `rememberBrainTurn`) will carry the key, and a missing field would 422 every grounded brain record on the public `POST/PUT /api/chats` boundary (the A2 quiet-failure path swallows it → grounded turns silently unsaved). No length cap needed — it is a single ref (`SourceRef`'s own field caps bind).
3. `app/api/chat.py` — the done-frame build (L1119-1133): pass `answer_image=source_ref_with_image(holder.shown_images[0]) if holder.shown_images else None` into `ChatDoneEvent` (the shared ref builder — source/path/title + `image_url` for the image doc; `holder.shown_images` is always empty on deflected turns — they run no agent — so deflected frames omit the key automatically).
4. ASSUMPTION: none beyond the phase's locked design (owner-confirmed 2026-09-26). The `query_log` row and the per-turn log line are UNCHANGED (the shown doc is a suggested/read doc already inside `record_docs`; `tool_calls` already counts the call — task 02).
## Testing & Quality
- Unit: `tests/unit/test_image_chips_and_answers.py` (extend) — the `ChatDoneEvent` serializer: `answer_image=None` → `model_dump()` has NO `answer_image` key (assert `"answer_image" not in dump`, and the JSON has no `null` field) and the dump is byte-identical to the pre-phase shape; `answer_image=SourceRef(source=…, path=…, title=…, image_url=…)` → the key present with all four fields; the existing `sources`/`related`/`suggestions` serialization is untouched in both cases. `ChatMessage` boundary: a brain record dict with `answer_image` (full ref shape) parses; with `answer_image=None`/absent parses (pre-phase records); the user record's `image` (str path) still parses independently; an `answer_image` missing `image_url` parses (the field is optional on `SourceRef`) — and the phase-83 value bounds still bind the ref's fields.
- Integration: `tests/integration/test_chat_api.py` (extend) — (a) a grounded turn whose mock chat client scripts a `show_image` tool call on a seeded image doc (the mock's scripted-tool mechanism, the `use your tools` precedent) → the final SSE `done` frame JSON carries `answer_image` with `image_url == /api/documents/<id>/image` and the cited `sources` include the same doc; (b) the SAME scripted flow with `answer_images=False` settings → no `show_image` in the offered tools (assert on the mock's recorded request `tools` list: 3 entries) and the `done` frame has NO `answer_image` key; (c) a zero-tool grounded turn (no scripted calls) → `done` frame has NO `answer_image` key (byte-identical to pre-phase). `tests/integration/test_chats_api.py` (extend) — the phase-113 regression pin: `POST /api/chats` with a brain record carrying `answer_image` → 201 (NOT a silent 422) and the served chat round-trips the ref byte-for-byte; a record without the key round-trips unchanged.
- Coverage: **>90%** on this task's new/modified code (serializer + boundary + turn tests carry it).
## Completion Criteria
- [ ] A turn whose agent called `show_image` → the `done` frame's JSON contains `answer_image` (source/path/title/image_url); every other turn's `done` frame is byte-identical to pre-phase (no key, no null).
- [ ] A saved chat persists and serves back a brain record with `answer_image`; pre-phase records (no key) round-trip byte-identically.
- [ ] Full test suite green; no behavior change in completed work.
@@ -0,0 +1,39 @@
# Task 05 — Bug fix: suggested-tier image docs earn their source chips
**Phase:** `124_image_chips_and_answers` · **Source:** `TODO.md:3` — "Image support is working but images don't show up as sources in the sources chips."
**Story:** n/a
## Objective
Close the observed bug deterministically: a retrieved image document in the suggested (seed) tier becomes a citation chip — WITHOUT the agent having to `read` it — because for an image doc the seed block already carries its ENTIRE content (summary == content == the vision description, phase 122 task 03), so "suggested" ≡ "read" and the phase-119 cite discipline is honored, not weakened.
## Work
1. `app/api/chat.py` — the `cited_docs` build (L1040-1046, currently the deduped `holder.read_docs` loop, the phase-119 read-docs-only contract per the L1027-1038 comment): AFTER that loop, add the auto-cite merge —
```python
# Phase 124 (TODO L3): a suggested-tier IMAGE doc's seed block IS its
# entire content (content == summary == the vision description) —
# "suggested" is equivalent to "read" for it, so it is auto-cited
# (the model never needs a read that would add only the marker line).
# Grounded turns only — a deflected answer cites nothing (unchanged).
if not plan.deflected:
for doc in plan.suggested_docs:
if not doc.is_image:
continue
key = (doc.source, doc.path)
if key not in cited_seen:
cited_seen.add(key)
cited_docs.append(doc)
```
Order: read docs first (read order, as today), then the auto-cited image docs in SUGGESTION order. Dedup: an image doc the agent also explicitly read is already in `cited_seen` from the first loop — it appears ONCE (at its read position). Update the L1027-1038 comment block to state the phase-124 addition (the read-docs-only rule gains its single documented exception: suggested-tier image docs).
2. Consequences to VERIFY (assert in tests, not code): (a) the related tier already excludes `cited_seen` members (`related_refs` filter, L1123-1127) — an auto-cited image doc leaves the "Nearby docs" row (no duplicate figure); (b) `record_docs` (L1049-1053, the `query_log.sources` + log-line `sources=` field) already includes `plan.suggested_docs` — the observability fields are UNCHANGED by this task; (c) a non-image suggested doc is never auto-cited (the `is_image` guard — the phase-119 read-docs rule stays intact for text docs: a zero-read text turn still chips nothing).
3. ASSUMPTION: none beyond the phase's locked A1 (scope = suggested tier only; deeper-ranked image docs keep the related-row figure — owner-confirmed 2026-09-26). No retriever change: `select_suggested`/`select_related` are untouched (the tiering math is out of scope — the fix is in the cited-tier COMPOSITION only).
## Testing & Quality
- Unit: `tests/unit/test_image_chips_and_answers.py` (extend) — factor the cited-docs merge into a small pure helper if the inline version is untestable in place (the executor's call — prefer a module-level `def merge_cited_docs(read_docs, suggested_docs, deflected: bool) -> list[Document]` in `app/api/chat.py` taking exactly those args, called from the turn pipeline; test the helper directly): (a) suggested image doc + zero reads → it is cited (with its position after reads); (b) suggested image doc + agent also read it → exactly once; (c) suggested TEXT doc + zero reads → NOT cited (phase-119 contract intact); (d) `deflected=True` → `[]` (weak hits cite nothing); (e) two suggested image docs → both, in suggestion order; (f) read order preserved for mixed read text docs.
- Integration: `tests/integration/test_chat_api.py` (extend) — the end-to-end pin of the bug fix: a grounded turn with NO scripted tool calls (the mock answers straight from the seed) whose suggested tier contains an image doc (seed the KB with the fixture image doc — the image-ON app settings + a mock vision description, the `test_image_documents.py` fixture pattern) → the `done` frame's `sources` include the image ref WITH `image_url` set (the chip + figure data), and `related` does NOT contain the same (source, path). A text-only KB turn → byte-identical `sources` to pre-phase (no auto-cite without image docs).
- Coverage: **>90%** on this task's new/modified code (the helper matrix + the integration pin carry it).
## Completion Criteria
- [ ] A zero-read grounded turn whose suggested tier holds an image doc → the sources chips (the `done` frame's `sources`) include the image doc with `image_url` — the phase-122 E2E's scripted `read` is no longer load-bearing for the chip.
- [ ] Text docs are unaffected: a zero-read text turn chips nothing (phase-119 contract); a deflected turn cites nothing.
- [ ] No duplicate: an auto-cited image doc does not also appear in the related row.
- [ ] Full test suite green; `query_log`/log-line `sources=` unchanged; no behavior change in completed work.
@@ -0,0 +1,27 @@
# Task 06 — The answer figure: live render, persistence, restore, shared page
**Phase:** `124_image_chips_and_answers` · **Source:** `TODO.md:3` — "…the supporting image should be shown in the chat if the model thinks it would be effective." + "Summaries should still be provided"
**Story:** n/a
## Objective
The client renders `answer_image` as a first-class figure in the brain bubble — below the answer text, above the source-chip row (A4) — persists it in the brain record, and restores it on refresh and on the shared page. The answer text is untouched and always rendered (the figure is ADDITIVE — "summaries should still be provided").
## Work
1. `frontend/assets/app.js` — a new `appendAnswerImage(wrap, ref)` helper (place it near `appendSourceImageFigure`, L1677 — it is its visual sibling but a different surface): build `<figure class="answer-image">` containing a lazy `<img>` (`loading="lazy"`, `src = ref.image_url`, the public bytes route) + a visible caption `<span class="answer-image-caption">`. `alt` = `ref.title` immediately, swapped to the doc's summary via the SAME `fetchContentSummary` pattern (L1716 — the phase-122 WCAG alt contract: a caption is ALWAYS visible, the summary arrives by fetch, any fetch failure keeps the title). An `img` `error` listener REMOVES the whole figure (the collapse rule — an answer whose image bytes are gone renders as a normal answer, never a broken icon; the chip row below still cites the doc). Navigation reuses the chip affordance: the `<figure>` is wrapped/behaves as the `documentUrl(ref.source, ref.path, "/")` href (the no-JS / context-menu escape hatch) with the left-click → same-page document modal (phase 26, the `openDocumentModal` pattern from `appendSources`/`appendSourceImageFigure`).
2. `frontend/assets/app.js` — the `done` handler (L2981+): right BEFORE `appendSources(wrap, ev.sources)` (L2999), `if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);` (the figure lands in `.msg-body` above the `.msg-meta` chip row — position per A4). The text bubble is untouched (the `delta`-accumulated markdown stays — the figure is appended, never replaces).
3. `frontend/assets/app.js` — the `rememberBrainTurn` call in the `done` handler (L3015+): the meta gains `answer_image: ev.answer_image || undefined` (the house undefined-drops-the-key pattern — the `related: ev.related?.length ? ev.related : undefined` line two above is the precedent; image-less / pre-phase turns store byte-identical records).
4. `frontend/assets/app.js` — `renderStoredMessage` (L1856), the BRAIN branch: right BEFORE `appendSources(wrap, m.sources)` (L1904), `if (m.answer_image) appendAnswerImage(wrap, m.answer_image);` (a record without the key renders byte-identically — pre-phase chats restore unchanged).
5. `frontend/assets/shared.js` — the shared page's brain message fragment builder (L354+, the phase-123 user-image renderer at L329-352 is the closest precedent): the same `if (m.answer_image)` figure render (the shared page uses the chat page's fragment builders per its L188 comment — wire it where the brain branch appends sources, mirroring app.js's ordering: figure before `addSources`). The bytes route is PUBLIC — the shared view is faithful (the phase-123 user-image contract).
6. `frontend/assets/styles.css` — the `.answer-image` family in the `.source-image` language (L784-815) but at answer scale: `figure.answer-image` — margin within the bubble's flow, max-width 100% of the bubble; `.answer-image img` — max-width 100%, max-height ≈320px (the bubble is the 46rem chat column — a full-width image would blow the turn's height; cap it), `object-fit: contain`, the theme's bordered-image treatment (`.source-image-img`'s border/background, L793), `display: block`; `.answer-image figcaption`/caption span — the `.source-image-caption` styling (L804) at answer size (the visible caption = the summary). Dark/light theme parity (the semantic color tokens the theme system uses — no hardcoded colors outside the token system, PLAN §7); contrast ≥4.5:1 for the caption (WCAG AA per AGENTS.md rule 5).
7. ASSUMPTION: none beyond the phase's locked A4 (owner-confirmed 2026-09-26). The max-height cap value (≈320px) is the one free number — the executor matches the theme's existing image scaling; the COLLAPSE-on-error rule and the alt=summary contract are locked, not free.
## Testing & Quality
- The UI is covered end-to-end by task 07's E2E suite (the house pattern — no separate JS unit suite; the E2E asserts the DOM): the live figure's presence/position (in `.msg-body`, before the `.msg-meta` chip row), its `src` (the bytes route) and lazy attribute, the caption/alt behavior, the collapse on a failed image load (assert the figure is ABSENT from the DOM, the answer text intact), persistence (the saved record carries `answer_image` — via the served saved chat), the restore render, and the shared-page render.
- Coverage: **>90%** on `app/` overall (this task is frontend — it must not regress the `app/` gate; the E2E in task 07 exercises it).
## Completion Criteria
- [ ] Live: a `done` frame with `answer_image` → the brain bubble shows the figure below the text, above the chips; the text is fully rendered; a frame without it renders byte-identically (no figure DOM).
- [ ] Restore: a saved chat with a brain record carrying `answer_image` renders the figure after reload; pre-phase records render unchanged.
- [ ] Shared: the shared page renders the figure for such a record.
- [ ] A lost image (bytes route 404) → the figure collapses (removed), never a broken-image icon; the answer + chips are intact.
- [ ] Full test suite green; no behavior change in completed work (the chip figure `appendSourceImageFigure` is untouched).
@@ -0,0 +1,32 @@
# Task 07 — The mock-LLM `show_image` trigger + isolated E2E suite
**Phase:** `124_image_chips_and_answers` · **Source:** `TODO.md:3` — the whole item (the E2E proves both halves: the chips fix and the answer-image feature, plus the toggle-off contract)
**Story:** n/a
## Objective
A deterministic E2E proof of the phase, run in isolation per AGENTS.md §4: the mock LLM learns to script a `show_image` call (and to NOT `read` — the zero-read turn is what makes the bug-fix assertion meaningful), and `tests/e2e/test_answer_images.py` drives the real user path on a toggle-ON app and a default-OFF app.
## Work
1. `tests/e2e/mock_llm.py` — a new scripted trigger (the documented marker-driven contract — extend the module docstring's trigger list):
- **Trigger:** the user message contains `show me the picture of` AND the system prompt contains `show_image` (the tool clause's presence — the toggle-ON gate: 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).
- **Flow:** on the FIRST matching request, stream ONLY `tool_calls` deltas — `show_image` with `path` = the combined `source/path` of the image document seeded in the `<documents>` blocks (the mock picks the seeded block whose `path` attribute ends in an image extension — `.png/.jpg/.jpeg/.webp/.gif/.bmp` — the fixture guarantees exactly one; the block-parsing machinery of the `use your tools` / tail-echo triggers is the precedent). Synthetic id `call_show`, `finish_reason: "tool_calls"`, no content. On the follow-up request (the tool result in context — recognize the `Image shown to the user` confirming line), stream the final answer: a short deterministic sentence ending with the mock's tail-echo of the `<documents>` block (the existing echo convention — it proves the seed was there) — NO further tool calls, NO `read` (the zero-read turn).
- **The bug-fix-only trigger:** the user message contains `which diagram is in your notes` (a second trigger, same image-doc pick, but NO `show_image` — the mock answers straight from the seed with ZERO tool calls; its answer quotes the tail of the seeded image doc's block — proving the description was in the prompt and that the chip appears with NO tool calls at all).
2. `tests/e2e/test_answer_images.py` (new) — module apps on the `test_image_documents.py` pattern (L88-186: dedicated ports, scratch dirs, `BOR_IMAGES=true`):
- **App A (feature ON):** `BOR_IMAGES=true` + `BOR_ANSWER_IMAGES=true`.
- **App B (default off):** `BOR_IMAGES=true`, `BOR_ANSWER_IMAGES` unset (the production default — the toggle-off contract).
- **KB fixture (both apps):** upload the single-PNG fixture archive (the `test_image_documents.py` archive builder, L201+) via the real admin upload flow → "Sync sources" → the image doc exists (assert on the Sources page, as that suite does). The mock's `IMAGE_DESCRIPTION_MODE` serves the description (the fixture doc's content/summary — already byte-stable).
- **Feature flow (App A):** open the chat → ask `<trigger question with "show me the picture of …">` → assert: (1) the brain bubble's `.msg-body` contains `.answer-image` with an `<img>` whose `src` is `/api/documents/<id>/image` (fetch the served bytes — 200, image Content-Type) and `loading="lazy"`; (2) the figure sits BEFORE the `.msg-meta` source-chip row (the A4 position); (3) the answer TEXT is non-empty (the "summaries still provided" contract); (4) the source-chip row carries the image doc's chip (the AUTO-CITE proof — the mock made ZERO `read` calls; only `show_image`) with its phase-122 figure; (5) the "Nearby docs" row does NOT repeat the image doc. Reload the page → the restored brain bubble shows the figure again (the `answer_image` persistence). Open the shared link (the existing share flow) → the shared page shows the figure.
- **Bug-fix-only flow (App A):** a fresh chat → ask the `which diagram is in your notes` trigger → ZERO tool calls → 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 tail.
- **Toggle-off flow (App B):** the same feature trigger question → the mock never sees `show_image` in the offered tools (assert via the mock's request log, the suite's existing mechanism where present — else assert the DOM: NO `.answer-image` in the brain bubble) and the `done` frame carries no `answer_image` (the saved chat's brain record has no `answer_image` key — fetch the saved chat via the API); the bug-fix trigger still chips the image doc (the chips fix is NOT gated by `BOR_ANSWER_IMAGES` — it belongs to `BOR_IMAGES`-indexed docs, per the phase design).
3. Run the suite IN ISOLATION: `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` (AGENTS.md §4/§9 — one file, its own app instances on dedicated ports; no cross-suite state).
4. ASSUMPTION: none beyond the phase's locked decisions (owner-confirmed 2026-09-26). The two trigger phrases are new and carry no collision with existing triggers (the mock's docstring lists every trigger — verify against it when writing; pick differently on collision).
## Testing & Quality
- This task IS the E2E suite; it also runs the final phase gate: `uv run pytest` (unit + integration) green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL **>90%** on `app/` (the validate.sh gate — if the new `app/` code (agent branch, prompts clause, schemas serializer, chat.py merge) dips the total, add unit pins, do NOT lower the bar); `uv run ruff check . && uv run pyright` clean.
- The mock change must not perturb any existing suite: run at least `tests/e2e/test_image_documents.py` and `tests/e2e/test_agent_document_tools.py` after the mock edit (the trigger is clause-gated, so pre-phase prompts never fire it — verify, don't assume).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` passes in isolation (feature flow, bug-fix-only flow, toggle-off flow — all assertions above).
- [ ] The mock's new trigger is inert for every existing suite (the two spot-checked suites pass unmodified).
- [ ] `uv run pytest` green; coverage TOTAL >90%; `ruff` + `pyright` clean.
- [ ] One atomic `--no-gpg-sign` commit for the phase code (per AGENTS.md §8 — the commit block in `00_phase.md`); the pipeline gate then moves the phase dir to `.agents/phases/complete/` (the house `phase: NN_name` bookkeeping commit, as for every completed phase).