phase: 123_chat_image_questions
Build and Push Containers / build-and-push-app (push) Successful in 1m54s
Build and Push Containers / build-and-push-db (push) Failing after 13s

All gates green. Verification complete.

**Phase 123 — final verification pass (all 4 tasks already in `complete/`)**

- Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer.
- `uv run pytest` → **2796 passed**, exit 0 (unit + integration).
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%).
- `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation.
- `uv run ruff check . && uv run pyright` → clean (0 errors).

**Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run).

No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
This commit is contained in:
2026-09-25 05:19:18 -04:00
parent a19d78d284
commit bef24e05e2
48 changed files with 3910 additions and 71 deletions
+36
View File
@@ -267,6 +267,24 @@
(role=alert). -->
<p class="char-count" id="char-count" hidden></p>
<!-- Phase 123 (task 02, TODO L6): the attach PREVIEW STRIP —
the selected image (thumbnail ≤48px + the filename + a
remove X) ABOVE the input row: the attachment is part of
the question being typed. `hidden` by default — app.js
reveals it only while a file is attached (attachedImage)
and clears it with the send (the strip must not linger
into the turn). With no pick — and with the images flag
off — it is never visible (A5's default-off contract).
The thumbnail is decorative (alt="" — the filename is
the visible, readable label beside it). -->
<div class="attach-preview" id="attach-preview" hidden>
<img class="attach-preview-img" alt="">
<span class="attach-preview-name"></span>
<button type="button" class="attach-preview-remove" id="attach-remove" aria-label="Remove the attached image">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
<!-- Composer (phase 48, 2026-08-29, TODO.md L3): one button, two
roles — #send-btn reads "Send" when idle and morphs into the
enabled "Stop" control (.is-stop, rose treatment) while a turn
@@ -284,6 +302,24 @@
`!text` guard in app.js is the real empty-input check (same
precedent as the tuning form's noValidate). -->
<form class="composer" id="composer" novalidate>
<!-- Phase 123 (task 02, TODO L6): the attach control — the
paperclip glyph button LEFT of the input (the icon style
of the other composer glyphs; aria-label is its
accessible name, the SVG is decorative). HIDDEN BY
DEFAULT — app.js reveals it only when the boot
/api/config says `images: true` (the brand boot's ONE
config request — no second round-trip); flag off (the
default) keeps the button hidden for good, so the
flag-off DOM is byte-identical to pre-phase (A5's
default-off contract). The hidden #attach-file input is
its backend (a programmatic .click() — the native
picker); accept mirrors the server's six-extension gate
(the client re-checks the name's extension before any
state change; the upload endpoint is the authority). -->
<button type="button" class="attach-btn" id="attach-btn" hidden aria-label="Attach an image">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
<input type="file" id="attach-file" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp" hidden>
<label class="visually-hidden" for="message-input">Ask Brain of Reese a question</label>
<!-- maxlength=4000 mirrors ChatRequest.message max_length=4000
(app/schemas.py) — the server 422s beyond; the #char-count