phase: 124_image_chips_and_answers
Build and Push Containers / build-and-push-app (push) Successful in 2m12s
Build and Push Containers / build-and-push-db (push) Failing after 1m39s

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.
This commit is contained in:
2026-09-26 17:06:09 -04:00
parent 7bd6335929
commit 62e4f6b572
65 changed files with 4909 additions and 46 deletions
+93
View File
@@ -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