phase: 123_chat_image_questions
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:
@@ -0,0 +1,50 @@
|
||||
# Phase 123 — Chat image questions: attach an image to a question
|
||||
|
||||
**Source:** `TODO.md` L6 — "…users should be able to submit images as part of their question in brain of reese."
|
||||
**Story:** n/a (feature request; completes the phase-122 image capability on the question side).
|
||||
**Context:** Phase 122 (todo, this pipeline) — `BOR_IMAGES` toggle + `GET /api/config` `images` flag (task 01), the ext→mime map, `image_dir` storage convention. `app/schemas.py:60` — `ChatRequest` (`message` min 1/max 4000, `history` ≤100 — `HistoryTurn` is text-only), `ChatMessage` (L742, `extra="forbid"`, phase-83 value bounds). `app/api/chat.py` — the turn pipeline: the user message is built at L645 (`{"role": "user", "content": request.message}`; a grounded turn runs `run_agent`, a deflected turn a direct `chat_stream` on the same `messages`), and `app/rag/agent.py:1370/1448` — `run_agent(..., user_message: str)` builds its own `[system, user]` (verify the data flow — if `run_agent` receives the already-built `messages`, the single edit site is chat.py). The phase-114 SSE error-frame-with-hint pattern (the "question too long" frame — `ChatErrorEvent.detail` + optional `hint`, consumed by the banner at app.js L2210). `frontend/index.html` — the composer (label L287, `#message-input` L292, `#send-btn` L325). `frontend/assets/app.js` — `handleSend` (L2306), `runTurn` (the turn driver + the user append/save-point-1 at send), `addMessage("user", …)` (user bubble), `rememberBrainTurn` (L2108, the brain save point), `renderStoredMessage` (L1642, user branch). `frontend/assets/shared.js` — the shared page's message render (text-only today). `app/api/config.py:30` — the public flags dict (phase 122 task 01 added `images`).
|
||||
|
||||
## Objective
|
||||
The user attaches one image to a question: a masked-by-server upload stores the bytes, the vision model (the chat model) receives a multimodal message, the user's bubble renders the image, the record persists the image path (not base64) so refresh and shared chats render it, and `BOR_IMAGES=false` rejects the request with a helpful hint.
|
||||
|
||||
## Dependencies
|
||||
- `122_image_documents` (todo) — CODE dependency: the `BOR_IMAGES`/`images` config flag (the toggle gates this feature), the ext→mime map, and the `image_dir` storage convention (this phase's `chat_image_dir` follows it).
|
||||
- Code dependencies (all complete): phase 14/50/55 conversation persistence, phase 74 history mapping, phase 114 SSE error-hint frames, phase 51 shared chats.
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **Storage (task 01, LOCKED A5):** user question-images are server-stored, NOT base64-in-saved-chats: `Settings.chat_image_dir: str = "~/bor-sources/chat-images"` (`BOR_CHAT_IMAGE_DIR`, the `image_dir` convention — a sibling of phase 122's `image_dir`, separate because question-images are per-conversation, not per-source) + `Settings.chat_image_max_mb: int = 10` (`BOR_CHAT_IMAGE_MAX_MB`, the ~10 MB cap of A5; `upload_max_mb`'s fail-loud validator precedent for `<= 0`). `POST /api/chat-images` (multipart, in `app/api/chat.py` or a small new `app/api/chat_images.py` router — the executor's call, following the repo's one-concern-per-module style): accepts an image file, validates the mime/ext against the SAME six-extension set as phase 122 (reuse the frozenset; the Content-Type header is a hint — the EXTENSION is the source of truth, the archive-uploader precedent), rejects oversize with a 413 (the fixed-detail style), stores `chat_image_dir/<uuid4().hex>.<ext>`, returns `{ "path": "/api/chat-images/<uuid-hex>.<ext>" }`. `GET /api/chat-images/{filename}` serves the bytes (404 on missing/unknown — the filename is a uuid, no enumeration value) with the phase-122 mime map; PUBLIC like saved-chat content (a saved chat's id is already its credential — phase 55 A1 — the image is part of that content).
|
||||
- **Request (task 01):** `ChatRequest.image: str | None = Field(default=None, max_length=500)` — a STORED PATH, pattern-validated (`^/api/chat-images/[0-9a-fA-F]{32}\.(png|jpe?g|webp|gif|bmp)$` — the stored filename is `uuid4().hex.<ext>`) — never a raw data URL (the upload endpoint already did the size/mime enforcement; re-validating a 10 MB base64 string in the schema would be the anti-pattern). Toggle OFF (`settings.images` false) with `image` set → the turn settles with the phase-114 SSE error frame: `detail` "Image support is turned off on this server." + `hint` "Enable BOR_IMAGES in the server's .env (and restart) to ask with an image." (the question itself is NOT persisted — a rejected turn saves nothing, the existing error-path convention). `image` set but file missing → the same frame shape with a "that image is no longer available" detail (a stale-path edge: the stored file was deleted out-of-band).
|
||||
- **Multimodal (task 01):** the user message becomes `{"role": "user", "content": [{"type": "text", "text": request.message}, {"type": "image_url", "image_url": {"url": <data URL from the stored file>}}]}` at BOTH construction sites (chat.py:645 and agent.py:1448 if it builds independently — verify the flow; when `request.image` is None the content stays the plain string, byte-identical to today). The data URL is built server-side from the stored bytes + mime map (the phase-122 `describe_image` data-URL construction — reuse it). `HistoryTurn`/`history_to_messages` are UNCHANGED (LOCKED A7): prior turns' images are never replayed into the model's history — the history budget is text, and a 10 MB image per past turn would blow every budget; the model simply sees the text of a prior turn that had an image.
|
||||
- **Persistence (tasks 01+02):** `ChatMessage.image: str | None = Field(default=None, max_length=500)` — the stored path, on the USER record (the image belongs to the question). The user record is saved at save-point-1 (send), BEFORE the turn resolves — so the client uploads FIRST (`POST /api/chat-images`) and stores the returned path in the user record, then POSTs `/api/chat` with `image=<path>`. Saved chats, shared chats, and the localStorage shape all carry the path (≤500 chars — no phase-83 cap pressure). A brain record never carries `image` (the answer may reference the image's sources, but the attachment is the user's).
|
||||
- **Composer (task 02):** the attach control appears ONLY when `GET /api/config` says `images: true` (phase 122's flag; fetched once at boot like the other config — the composer reads the existing cached config if present). A paperclip button (SVG, the icon style of the other composer glyphs, `aria-label="Attach an image"`) before the input → hidden `<input type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp">` → on select: a preview strip above the input (thumbnail ≤48px, the filename, a remove ✕) + the file's data URL kept client-side until send; on send with an attachment: `POST /api/chat-images` (the file) → the returned path goes into the user record + the `/api/chat` body → the preview clears. Upload failure (oversize, non-image, server down) → the phase-114-style out-of-turn banner ("Couldn't attach the image — …") and the send is BLOCKED (no question without the image the user attached — ASSUMPTION A8, locked below). The user bubble renders the image (from the data URL live, from the stored path after restore) with `alt = filename`, capped height, above/beside the text (the theme's bubble treatment; the image is part of the question, visible in both the live bubble and the restore).
|
||||
- **Restore + shared (task 03):** `renderStoredMessage`'s user branch: `m.image` present → the user bubble includes `<img src="{m.image}" alt="…">` (a load failure collapses to a small "image unavailable" line — never a broken icon). The shared page (`shared.js`) renders the user image the same way (the image route is public — the shared view is faithful; no new shared-shape field beyond `ChatMessage.image`, which the public `messages` shape already carries).
|
||||
- **NOT touched:** the history budget/trimming, the honesty gate, the suggestion chips, phase-122's document-image pipeline (a QUESTION image is a separate concern — it is NOT indexed as a document), and the stop/failed-turn paths (they persist whatever records exist, including the new `image` key, unmodified).
|
||||
|
||||
## Tasks
|
||||
1. `01_vision_request.md` — `POST/GET /api/chat-images`, `ChatRequest.image` + `ChatMessage.image`, the toggle-off/stale error frames, the multimodal user message at both construction sites.
|
||||
2. `02_composer_attach.md` — the config-gated attach control, preview, upload-then-send, the user bubble's image.
|
||||
3. `03_restore_and_shared.md` — `ChatMessage.image` on restore (chat page) and on the shared page.
|
||||
4. `04_chat_image_tests.md` — unit + integration + isolated E2E `test_chat_image_questions.py`.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_chat_image_questions.py` (new, task 04) — the path pattern validator (accepts well-formed, rejects data URLs / wrong ext / traversal), the multimodal message build (both sites; `image=None` → byte-identical plain string), the toggle-off + stale-file error frames (detail + hint shapes), the upload endpoint's mime/size/ext rules (tmp-dir settings), the serve route (200/404), `ChatMessage.image` bounds + omission.
|
||||
- Integration: `tests/integration/test_chat_api.py` (extend, task 04) — upload → `POST /api/chat` with `image=<path>` → the mock client RECEIVES the multimodal content list (text part + image_url data URL); `image` with `images=false` → the SSE error frame with the hint and NO model call, no persisted record; `image=None` requests are byte-identical to pre-phase; a saved chat round-trips a user record with `image`; a shared chat serves it.
|
||||
- E2E: `tests/e2e/test_chat_image_questions.py` (new, task 04) — isolated run per AGENTS.md §4, `BOR_IMAGES=true`: attach a fixture PNG in the composer → preview + remove works → send → the user bubble shows the image → the (mock) answer streams → reload → the user bubble restores WITH its image → open the shared link → the shared page shows the image. Plus the default-off negative: with `BOR_IMAGES` unset, the attach control is ABSENT from the DOM.
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] With `BOR_IMAGES=true`: attach → send → the vision model gets text+image; the user bubble, the refreshed page, and the shared chat all show the image; the saved chat stores the PATH (assert no base64 in the stored payload).
|
||||
- [ ] With `BOR_IMAGES=false`: the attach control is absent, an API request with `image` gets the hinted error frame, and no model call / record happens.
|
||||
- [ ] Text-only questions behave byte-identically to pre-phase (the multimodal branch is inert).
|
||||
- [ ] `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
|
||||
- **A5 — one image per question; the ~10 MB cap (`BOR_CHAT_IMAGE_MAX_MB`); server-stored bytes under `chat_image_dir`; the saved/shared record carries the path, never base64 (owner-confirmed 2026-09-24, roadmap confirmation).**
|
||||
- **A7 — a question's image applies to the CURRENT turn only; prior turns' images are never replayed into the model's history (the text of a prior turn stands alone) (owner-confirmed: same confirmation — the proposed design).**
|
||||
- **A8 — if the image upload fails, the send is blocked with a banner (the question is never sent without the image the user attached) (owner-confirmed: same confirmation).**
|
||||
|
||||
## Commit
|
||||
```bash
|
||||
git add app/ frontend/ tests/ .env.example .agents/phases/ && git commit --no-gpg-sign -m "feat(chat): attach an image to a question — vision input, in-bubble render, persisted and shared"
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
# Task 01 — Vision request: upload/serve endpoints, request + message schemas, multimodal build
|
||||
|
||||
**Phase:** `123_chat_image_questions` · **Source:** `TODO.md:6` — "…users should be able to submit images as part of their question in brain of reese."
|
||||
|
||||
## Objective
|
||||
The server side of the image question: a uuid-named upload/serve pair for question images, `ChatRequest.image` (stored path) + `ChatMessage.image` (persistence), the toggle-off/stale error frames, and the multimodal user message at both construction sites — with text-only requests byte-identical to pre-phase.
|
||||
|
||||
## Work
|
||||
1. `app/config.py` — `chat_image_dir: str = "~/bor-sources/chat-images"` (`BOR_CHAT_IMAGE_DIR`, the phase-122 `image_dir` convention) + `chat_image_max_mb: int = 10` (`BOR_CHAT_IMAGE_MAX_MB`, the A5 cap; the `upload_max_mb` fail-loud `<= 0` validator precedent). `.env.example` entries.
|
||||
2. New router (a small `app/api/chat_images.py`, registered in `app/main.py` next to the chat router — the one-concern-per-module house style):
|
||||
- `POST /api/chat-images` — `UploadFile` (the git-sources upload endpoint L432 pattern): extension must be in the phase-122 image frozenset (the EXTENSION is the source of truth — a Content-Type header is a hint); total bytes capped at `chat_image_max_mb` (stream-count the bytes — reject with 413 + a fixed detail that names the cap, never echoing the filename); store `chat_image_dir/<uuid4().hex>.<ext>` (dir created on demand); response `{"path": "/api/chat-images/<uuid>.<ext>"}`.
|
||||
- `GET /api/chat-images/{filename}` — filename must be `<uuid-hex>.<ext>` (the regex guard → 404 otherwise, no path traversal by construction); 404 on missing file; serve the bytes with the phase-122 ext→mime map + `Cache-Control: private, max-age=3600` (public, like saved-chat content — phase 55 A1).
|
||||
3. `app/schemas.py`:
|
||||
- `ChatRequest` (L60): `image: str | None = Field(default=None, max_length=500)` + a `field_validator` — when set, it must match `^/api/chat-images/[0-9a-fA-F]{32}\.(png|jpe?g|webp|gif|bmp)$` (the uuid4().hex shape — 32 hex chars; adjust if the uuid format differs) with a fixed 422 detail ("image must be an uploaded chat image path" — no echo). Docstring: the path from `POST /api/chat-images` (task 01) — never a data URL; the upload endpoint owns size/mime enforcement.
|
||||
- `ChatMessage` (L742, `extra="forbid"`): `image: str | None = Field(default=None, max_length=500)` — on the USER record only (the question's attachment); the docstring notes brain records never carry it and the saved/shared shape therefore gains one optional key (omitted when None — the phase-50 byte-identical contract for text-only chats holds).
|
||||
4. `app/api/chat.py` — the turn pipeline:
|
||||
- pre-stream validation (BEFORE any model call, at the top of the turn handler): `request.image` set → `settings.images` false → yield the phase-114 error frame (`ChatErrorEvent(detail="Image support is turned off on this server.", hint="Enable BOR_IMAGES in the server's .env (and restart) to ask with an image.")`) and return (NO model call, NO record — the existing error-path convention); file missing on disk → the same frame shape, `detail="That image is no longer available."` + a generic reachability-free hint (or no hint — the banner's default is fine).
|
||||
- the user message (L645): when `request.image` is set, `{"role": "user", "content": [{"type": "text", "text": request.message}, {"type": "image_url", "image_url": {"url": <data URL>}}]}` — the data URL built from the stored bytes + the phase-122 mime map (REUSE the data-URL construction from `describe_image` — factor it to a shared helper if it is buried in `app/rag/summarizer.py`); `request.image` None → the plain-string content, byte-identical.
|
||||
- `app/rag/agent.py` — verify the data flow: if `run_agent` (L1370) receives the already-built `messages` from chat.py, NO change here (the L1448 build is for a different entry); if it builds its own user message from `user_message`, extend `run_agent`'s signature (`user_message: str | list | None` — pyright-clean) and make chat.py pass the multimodal content. Pin the chosen flow in a code comment.
|
||||
- a QUESTION image is NEVER indexed as a document (no importer call) — it is turn-local storage.
|
||||
5. ASSUMPTION (A7 re-stated): `HistoryTurn`/`history_to_messages` unchanged — prior turns' images are not replayed (text-only history stands).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_chat_image_questions.py` (task 04 finalizes) — the path validator (well-formed ok; a data URL, a wrong ext, a traversal, and a 31-hex-char uuid all 422); the multimodal builder (both sites; None → plain string); the error frames' exact detail/hint strings; the upload endpoint's ext/size rules (tmp `chat_image_dir`); the serve route 200/404 + Content-Type; `ChatMessage.image` (bounds, omission, the `extra="forbid"` boundary intact).
|
||||
- Integration: `tests/integration/test_chat_api.py` (task 04) — the upload → chat flow asserts the MOCK client received the multimodal content list; the toggle-off frame + no model call; text-only byte-identity; saved + shared round-trips with `image`.
|
||||
- Coverage: **>90%** on the touched modules.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `POST /api/chat-images` stores a uuid-named file and returns its path; `GET` serves it; oversize/non-image → 413/422 with fixed details.
|
||||
- [ ] `POST /api/chat` with `image` (toggle on) delivers a multimodal user message to the model; toggle off → the hinted error frame, no model call; `image=None` → byte-identical behavior.
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Task 02 — Composer: attach control, preview, upload-then-send, the user bubble's image
|
||||
|
||||
**Phase:** `123_chat_image_questions` · **Source:** `TODO.md:6` — "…users should be able to submit images as part of their question in brain of reese."
|
||||
|
||||
## Objective
|
||||
The composer (when `GET /api/config` says `images: true`) takes one attached image — preview + remove before send, upload on send, the image in the user's bubble — and the user's conversation record carries the stored `image` path.
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html` — the composer (the label L287 / `#message-input` L292 / `#send-btn` L325 region): before the input, the attach control
|
||||
```html
|
||||
<button type="button" class="attach-btn" id="attach-btn" hidden aria-label="Attach an image">
|
||||
<svg aria-hidden="true" …paperclip glyph, the icon style of the other composer glyphs…></svg>
|
||||
</button>
|
||||
<input type="file" id="attach-file" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp" hidden>
|
||||
```
|
||||
plus the preview strip container (after the input row, `#attach-preview`, `hidden` by default — a thumbnail ≤48px + filename + a remove ✕ button `#attach-remove`). `#attach-btn` is `hidden` by default — JS reveals it only when the config flag is on (task 02 step 3); the hidden-by-default markup keeps the flag-off DOM byte-identical (A5's default-off contract).
|
||||
2. `frontend/assets/app.js`:
|
||||
- boot: read `images` from the `GET /api/config` fetch (the composer already consumes the cached whoami/config boot — extend that fetch's result use; ONE request, no extra round-trip) → `attachBtn.hidden = !images`.
|
||||
- attach flow: `#attach-btn` click → `attachFile.click()`; on change: validate the file's extension against the six (client-side pre-check, the server re-validates — a bad pick → the out-of-turn banner "Only PNG, JPEG, WebP, GIF, and BMP images can be attached." and no state change); keep `{ file, dataUrl (for the live preview) }` in a turn-local `attachedImage` var; show `#attach-preview` (thumbnail from the data URL, the filename, the remove ✕); the remove ✕ (or a new selection) clears the state + hides the strip.
|
||||
- send flow (`handleSend` L2306 / `runTurn`): when `attachedImage` is set:
|
||||
1. `POST /api/chat-images` (the File) — on failure (413/422/5xx) → the phase-114-style out-of-turn banner with the server's detail ("Couldn't attach the image — try again.") and the send is BLOCKED (LOCKED A8 — the question is never sent without its image; the input text stays).
|
||||
2. success → `runTurn(text, { image: <returned path> })`; `runTurn`'s user append (save point 1 — the user record) stores `{ who: "user", text, image: <path> }` (the `image` key joins the `bor.chat.v1` record — the phase-14 shape gains the optional key; `saveConversation()` + the phase-55 auto-save ride the existing path);
|
||||
3. the USER bubble renders the image: extend `addMessage("user", text)` with an optional `image` arg (data URL live, path after restore) → `<img src alt={filename}>` in the bubble (capped height ~240px, `max-width: 100%`, the theme's bubble treatment, `loading="lazy"`);
|
||||
4. clear `attachedImage` + the preview strip AFTER the user bubble is rendered (the strip must not linger into the turn).
|
||||
- text-only sends: `attachedImage` null → the request body omits `image`, the user record omits the key, the bubble is byte-identical to pre-phase.
|
||||
3. `frontend/assets/styles.css` — `.attach-btn` (the composer glyph button treatment — match the send-btn family, focus-visible ring per PLAN §7), `#attach-preview` (the strip: flex row, thumbnail box, filename ellipsis, the ✕), the user-bubble image block.
|
||||
4. ASSUMPTION (A8 re-stated): an upload failure blocks the send (no partial question-without-image) — the banner tells the user what failed; the typed question is preserved.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_chat_image_questions.py` (task 04) — house-style source assertions: `#attach-btn` is `hidden` by default + `aria-label`; the reveal is gated on the config `images` flag; the extension pre-check list matches the server's six; the send path uploads BEFORE `runTurn` and blocks on failure (the A8 ordering); the user record gains `image` only when attached; the user bubble renders the `img` with `alt`.
|
||||
- E2E: `tests/e2e/test_chat_image_questions.py` (task 04) — the composer scenarios.
|
||||
- Coverage: n/a (frontend) — the validate.sh `app/` gate must stay green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Flag on: attach → preview → remove all work; send with an attachment uploads, the user bubble shows the image, and the question reaches the model.
|
||||
- [ ] Flag off: the attach button is ABSENT from the DOM; a hand-crafted `image` request still gets the server's error frame (task 01's contract, unchanged).
|
||||
- [ ] A text-only send produces the same request body and DOM as pre-phase (byte-check in the E2E where practical).
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Task 03 — Restore + shared: the question's image survives a refresh and a share link
|
||||
|
||||
**Phase:** `123_chat_image_questions` · **Source:** `TODO.md:6` — "…users should be able to submit images as part of their question in brain of reese."
|
||||
|
||||
## Objective
|
||||
A user record carrying `image` (the stored path) renders its image when the chat is restored from localStorage / the saved-chat API, and on the shared-chat page — a load failure degrades to a small note, never a broken icon.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/app.js` — `renderStoredMessage(m)` (L1642), the USER branch: when `m.image` is present, the restored user bubble includes `<img src="{m.image}" alt="{m.text || 'attached image'}">` through the SAME bubble-image helper task 02 built for the live bubble (one renderer — the live bubble passes the data URL, restore passes the path; the helper takes any `src`). `onerror` → replace the image with a small "image unavailable" line (the file was deleted out-of-band — the row keeps its path, the render degrades).
|
||||
- The restore paths that call `renderStoredMessage` (the localStorage restore ~L1700 and the saved-chat restore ~L1770) need NO other change — the record's `image` key flows through the phase-14/50 restore as any optional key.
|
||||
- `retryLastTurn` / regenerate: a RE-ASK of a question that had an image does NOT re-attach the image (the redo re-sends `prev.text` only — LOCKED A7, the image is turn-local to the original send; the restored image stays visible in the replaced record until the redo pops it, which is the existing redo-in-place behavior).
|
||||
2. `frontend/assets/shared.js` — the shared page's message render (its text-only loop over `messages`): the user-record branch gains the same image render (the image route is public — a shared chat is faithful; the `alt` + `onerror` degradation are identical to the chat page).
|
||||
3. `frontend/assets/styles.css` — no new rules beyond what task 02 added (the shared page reuses the bubble-image block; verify the shared page's bubble class shares it — if the shared page uses a different bubble class, scope the image rule to both).
|
||||
4. ASSUMPTION: the `image` key is optional and absent in every pre-phase saved chat — no data migration, no backfill (old chats have no question-images to restore).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_chat_image_questions.py` (task 04) — house-style source assertions: the user-branch render reads `m.image` and reuses the bubble-image helper; the `onerror` degradation exists on BOTH pages; the shared render includes the image; the redo path sends `prev.text` only (no `image` on the re-ask).
|
||||
- Integration: `tests/integration/test_chats_api.py` (extend, task 04) — a user record with `image` round-trips `POST`/`PUT /api/chats` and serves through `GET /api/shared/{token}` (the public shape carries it).
|
||||
- E2E: the refresh + shared scenarios of `tests/e2e/test_chat_image_questions.py` (task 04).
|
||||
- Coverage: n/a (frontend) — the validate.sh `app/` gate must stay green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Reload after an image question: the user bubble shows the image (from the stored path) + the rest of the conversation is unchanged.
|
||||
- [ ] The shared link renders the image on the shared page.
|
||||
- [ ] A deleted image file degrades to the "image unavailable" line on both pages (no broken-image icon).
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Task 04 — Chat-image tests: unit + integration + isolated E2E
|
||||
|
||||
**Phase:** `123_chat_image_questions` · **Source:** `TODO.md:6` — "…users should be able to submit images as part of their question in brain of reese."
|
||||
|
||||
## Objective
|
||||
Pin the whole question-image contract: the upload/serve rules, the multimodal model payload, the toggle-off rejection, the persistence shape (path, never base64), the composer gating, and the refresh/share rendering — as an isolated Playwright suite.
|
||||
|
||||
## Work
|
||||
1. `tests/unit/test_chat_image_questions.py` (new) — consolidates the per-task unit cases (tasks ship code; this task ships the full pin):
|
||||
- config: `chat_image_dir`/`chat_image_max_mb` defaults + env overrides;
|
||||
- the `ChatRequest.image` validator (well-formed path ok; data URL / wrong ext / traversal / malformed uuid → 422, fixed details, no echo);
|
||||
- the multimodal builder (text part + `image_url` data-URL part, correct mime; `image=None` → the plain string, byte-identical); the data-URL helper is shared with `describe_image` (assert the import, not a copy);
|
||||
- the error frames: toggle-off (exact detail + hint strings), stale file (exact detail) — both settle the turn WITHOUT a model call (the mock client must see zero calls);
|
||||
- the upload endpoint: the six exts accepted, others 422/413-style per the spec, oversize → 413 (fixed detail naming the cap), the stored filename is `<uuid-hex>.<ext>`;
|
||||
- the serve route: 200 + Content-Type per ext, 404 for missing/unknown/traversal filenames;
|
||||
- `ChatMessage.image` (max 500, omission when None, `extra="forbid"` intact — an unknown key still 422s);
|
||||
- frontend source assertions (tasks 02+03): the attach button hidden-by-default + config-gated reveal, the A8 upload-before-send ordering + block-on-failure, the user record's `image` key, the shared bubble render, the `onerror` degradation, the redo sends text-only.
|
||||
2. `tests/integration/test_chat_api.py` (extend, task 01's cases):
|
||||
- `POST /api/chat-images` → `POST /api/chat` with the returned path → the MOCK client received the multimodal content list (text == the question, data URL decodes to the uploaded bytes);
|
||||
- `images=false` + `image` → the SSE error frame with the hint; the mock client got NO call; NO saved record;
|
||||
- `image=None` → the model payload is byte-identical to a pre-phase request;
|
||||
- a saved chat (and a shared one) round-trips a user record with `image` — and assert the stored payload contains NO base64 (the path only — the A5 contract).
|
||||
3. `tests/integration/test_chats_api.py` (extend, task 03's case): the shared-chat serve includes the user record's `image` path.
|
||||
4. `tests/e2e/test_chat_image_questions.py` (new — isolated run per AGENTS.md §4: `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov`), `BOR_IMAGES=true` for this suite's app instance (the phase-122 E2E env-override pattern):
|
||||
- attach a fixture PNG in the composer → the preview strip shows (thumbnail + filename) → remove → the strip clears and the file state is gone;
|
||||
- re-attach → send → the user bubble shows the image; the mock LLM's (text-only) answer streams normally (the mock ignores the image part — the assertion is on the REQUEST the server built, verified via the mock's capture);
|
||||
- `page.reload()` → the user bubble restores WITH its image (the stored path, not the data URL — the request count for the image route confirms the path fetch);
|
||||
- share the chat (the existing share flow) → open the shared link → the shared page shows the user's image;
|
||||
- default-off negative (a second app instance or the suite's flag-off fixture): `#attach-btn` is ABSENT from the DOM; a direct `POST /api/chat` with an `image` path returns the hinted error frame (no model call).
|
||||
5. Run the full gate: `uv run pytest`, `uv run pytest --cov=app --cov-report=term-missing` (TOTAL >90%), the isolated E2E file, `uv run ruff check . && uv run pyright`.
|
||||
|
||||
## Testing & Quality
|
||||
- This task IS the phase's test suite (see Work).
|
||||
- Coverage: **>90%** on `app/` — the phase's `app/` surface (config, the chat-images router, the chat pipeline, the schemas) is fully exercised.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] All test artifacts exist and pass; the isolated E2E file passes standalone.
|
||||
- [ ] The multimodal payload, the no-base64-in-storage, and the toggle-off rejection are each asserted at the unit AND integration level.
|
||||
- [ ] `uv run pytest --cov=app` TOTAL >90%; lint + types clean.
|
||||
Reference in New Issue
Block a user