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 -11
View File
@@ -378,15 +378,22 @@ def test_composer_form_is_novalidate() -> None:
def test_run_turn_is_the_extracted_turn_handler() -> None:
"""Phase 49 (owner-locked 2026-08-29, TODO.md L4): the turn
machinery is extracted from handleSend into
`runTurn(text, { reask = false })`. handleSend keeps only the
form-level pre-work (the in-flight stop guard, the !text guard, the
composer pre-work) and delegates; the user append + persistence
save point 1 (push + save) sit in runTurn's `!reask` block — the
redo-in-place retry path skips both, because the question is
already in the DOM and in `conversation`."""
`runTurn(text, { reask = false, image = null })` (the `image`
argument is phase 123 task 02's optional attachment). handleSend
keeps the form-level pre-work (the in-flight stop guard, the
!text guard, the composer pre-work) plus — since phase 123 — the
locked-A8 upload step (an attached image uploads BEFORE the
input is cleared; a failed upload blocks the send) and delegates;
the user append + persistence save point 1 (push + save) sit in
runTurn's `!reask` block — the redo-in-place retry path skips
both, because the question is already in the DOM and in
`conversation`."""
js = _js()
assert re.search(r"async function runTurn\(text, \{ reask = false \} = \{\}\)", js), (
"runTurn(text, { reask = false }) must be the extracted turn handler"
assert re.search(
r"async function runTurn\(text, \{ reask = false, image = null \} = \{\}\)", js
), (
"runTurn(text, { reask = false, image = null }) must be the extracted "
"turn handler"
)
handle = js.find("async function handleSend")
turn = js.find("async function runTurn")
@@ -397,7 +404,12 @@ def test_run_turn_is_the_extracted_turn_handler() -> None:
assert 'input.value = ""' in handle_body
assert "autoGrow()" in handle_body
assert "clearErrorBanner()" in handle_body
assert "runTurn(text, { reask: false })" in handle_body, ("handleSend delegates the turn")
# Phase 123 (task 02, locked A8): the delegation carries the
# upload step's `image` ({ path, src, alt } | null) — null for a
# text-only send (the pre-phase shape).
assert "runTurn(text, { reask: false, image })" in handle_body, (
"handleSend delegates the turn (with the attached image's path)"
)
assert 'addMessage("user"' not in handle_body, (
"the user append moved with the turn into runTurn"
)
@@ -412,8 +424,21 @@ def test_run_turn_is_the_extracted_turn_handler() -> None:
)
turn_top = js[turn:wrap_idx]
assert "if (!reask) {" in turn_top, "the reask gate guards the append + push"
assert 'addMessage("user", renderMarkdown(text), true)' in turn_top
assert 'conversation.push({ who: "user", text })' in turn_top
# Phase 123 (task 02): the user append + push carry the optional
# attachment — the bubble gets { src, alt } (the data URL live; the
# stored path is the fallback) and the record gains the `image`
# key (the STORED path — A5: never base64) only when one exists;
# a null image keeps the pre-phase shapes verbatim. The strip must
# not linger into the turn (cleared after the bubble renders).
assert re.search(
r'addMessage\(\s*"user",\s*renderMarkdown\(text\),\s*true', turn_top
), "the submit must reveal the user message (scroll intent true)"
assert "image ? { src: image.src || image.path, alt: image.alt } : null" in turn_top
assert "{ who: \"user\", text, image: image.path }" in turn_top
assert "{ who: \"user\", text }" in turn_top
assert "clearAttachedImage()" in turn_top, (
"the preview strip must not linger into the turn"
)
assert "saveConversation()" in turn_top