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
File diff suppressed because it is too large Load Diff
+17 -3
View File
@@ -126,10 +126,24 @@ def test_raw_text_only_stored_and_re_rendered_on_restore() -> None:
def test_save_points_user_on_send_and_brain_on_done() -> None:
"""Save points: the user message is stored the moment it is sent (BEFORE
the fetch — a failed turn keeps the question); the brain message is
stored on `done` with the done metadata (sources/deflected/suggestions)."""
stored on `done` with the done metadata (sources/deflected/suggestions).
Phase 123 (task 02): the user push is conditional — the record gains
the optional `image` key (the STORED path from the upload step,
A5: never base64) only when an attachment exists; the text-only
branch is the pre-phase object verbatim. The save point (push +
save before the turn starts) is the contract."""
js = _js()
user_push = js.find('conversation.push({ who: "user", text })')
assert user_push != -1
run_turn = js.find("async function runTurn")
assert run_turn != -1, "runTurn must exist (the phase-49 extraction)"
user_push = js.find("conversation.push(", run_turn)
push_block = js[user_push : js.find("saveConversation()", user_push)]
assert '{ who: "user", text, image: image.path }' in push_block, (
"an attached question stores the image path (A5)"
)
assert '{ who: "user", text }' in push_block, (
"a text-only question keeps the pre-phase record shape"
)
assert user_push < js.find('fetch("/api/chat"'), (
"the user message must be saved before the turn starts"
)
+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
+24 -7
View File
@@ -93,12 +93,16 @@ def test_scroll_helper_is_unconditional() -> None:
def test_add_message_takes_explicit_scroll_intent() -> None:
"""addMessage(who, html, scroll = false): the phase-18
"""addMessage(who, html, scroll = false, image = null): the phase-18
scrollBehavior/force parameters are gone; the bubble scrolls only
when the caller explicitly asks (submit reveal, restore landing)."""
when the caller explicitly asks (submit reveal, restore landing).
Phase 123 (task 02) appended the optional `image` argument (the
question's attached image — { src, alt } on a user bubble, the ONE
renderer for live + restore + shared); the scroll contract is
untouched."""
js = _js()
body = _fn_body(js, "addMessage")
assert "function addMessage(who, html, scroll = false)" in body
assert "function addMessage(who, html, scroll = false, image = null)" in body
assert "if (scroll) scrollReveal(wrap)" in body
assert "force" not in body
assert "scrollBehavior" not in body
@@ -119,9 +123,13 @@ def test_submit_reveals_user_message() -> None:
assert turn != -1, "runTurn must exist (phase 49 extraction)"
body = js[turn : js.find("\n}\n", turn)]
assert "if (!reask) {" in body, "the user append is gated on !reask"
assert 'addMessage("user", renderMarkdown(text), true)' in body, (
"the submit must reveal the user message (scroll intent true)"
)
# Phase 123 (task 02): the user append gained the optional image
# argument (the attached image's { src, alt }) — the call is
# multi-line now; the contract is the same: user + the raw text +
# the explicit scroll intent true.
assert re.search(
r'addMessage\(\s*"user",\s*renderMarkdown\(text\),\s*true', body
), "the submit must reveal the user message (scroll intent true)"
for call in re.findall(r'addMessage\("brain"([^)]*)\)', body):
assert "true" not in call, (
f"streaming brain bubbles must not scroll the page: {call!r}"
@@ -168,7 +176,16 @@ def test_restore_landing_is_one_shot() -> None:
assert 'addMessage("brain", renderMarkdown(m.text), true)' in body
assert js.count('"auto", true') == 0, "the old forced 'auto' landing must be gone"
# Submit reveal + the two restore landings — nothing else scrolls.
assert js.count(", true)") == 3, "only submit + the two restore calls may scroll"
# Phase 123 (task 02): the submit call is multi-line (the optional
# image argument follows the scroll intent), so it no longer ends
# in the single-line ", true)" literal — the two restore calls do;
# the submit reveal is counted by its own (multi-line) shape.
assert js.count(", true)") == 2, (
"only the two restore calls may scroll (single-line shape)"
)
assert len(re.findall(r'addMessage\(\s*"user",\s*renderMarkdown\(text\),\s*true', js)) == 1, (
"the submit reveal may scroll (multi-line since phase 123's image argument)"
)
# The marker comment documents the one-shot, load-time contract.
assert "restore landing" in body
assert "one-shot" in body
+22 -7
View File
@@ -313,9 +313,11 @@ def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
`.chat-shell` is the `.chat-bottom` wrapper — NO id (nothing in JS
binds it; the bindings live on the inner elements, the move is pure
HTML/CSS) — holding the `.chat-actions` row, the phase-104
`#char-count` counter, and the `#composer` form, in that order: the
row + counter + composer are ONE sticky unit, and the wrapper owns
the shell's bottom slot, so the sticky shift range is still that
`#char-count` counter, the phase-123 `#attach-preview` strip (hidden
by default — zero height at rest, the sticky geometry untouched),
and the `#composer` form, in that order: the row + counter +
preview + composer are ONE sticky unit, and the wrapper owns the
shell's bottom slot, so the sticky shift range is still that
column's box (a sibling after it would carve the range away and
re-break the pin). The composer form keeps `novalidate` and its
contract ids."""
@@ -331,11 +333,12 @@ def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
"elements"
)
kids = last["children"]
assert len(kids) == 3, (
"the unit holds exactly three element children: .chat-actions, "
"then #char-count (phase 104), then #composer"
assert len(kids) == 4, (
"the unit holds exactly four element children: .chat-actions, "
"then #char-count (phase 104), then #attach-preview (phase 123), "
"then #composer"
)
row, counter, form = kids
row, counter, preview, form = kids
assert row["tag"] == "div" and (
row["attrs"].get("class") or ""
).split() == ["chat-actions"], (
@@ -354,6 +357,18 @@ def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
"the counter ships hidden — it appears only from 80% of the "
"4,000-char cap (app.js updateCharCount)"
)
# Phase 123 (task 02, TODO L6): the attach preview strip — a
# hidden-by-default div between the counter and the composer (the
# selected image above the input row; zero height while hidden, the
# pinned-cluster geometry untouched).
assert preview["tag"] == "div" and (
preview["attrs"].get("id") == "attach-preview"
), "the third child is the phase-123 #attach-preview strip"
assert (preview["attrs"].get("class") or "") == "attach-preview"
assert "hidden" in preview["attrs"], (
"the strip ships hidden — it appears only while a file is "
"attached (app.js attachedImage)"
)
assert form["tag"] == "form" and form["attrs"].get("id") == "composer"
assert "novalidate" in form["attrs"], (
"phase 48: the composer form stays `novalidate` (a `required` "
+22 -9
View File
@@ -696,11 +696,11 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
from the top of the column to the bottom: below the ``#messages``
section, directly above the composer; nothing but the row's own
comment lands between ``#messages`` and the row, and nothing but the
phase-104 ``#char-count`` counter + the composer comment lands
between the row and the composer (the counter is hidden by default —
zero height, the pinned-cluster geometry untouched). No
other page carries ``.chat-actions`` (chat-page only, like the
pills)."""
phase-104 ``#char-count`` counter + the phase-123 attach preview
strip + the composer comment lands between the row and the composer
(both hidden by default — zero height at rest, the pinned-cluster
geometry untouched). No other page carries ``.chat-actions``
(chat-page only, like the pills)."""
html = _index()
start = html.find('<div class="chat-actions">')
assert start != -1, "index.html must carry the .chat-actions wrapper"
@@ -734,8 +734,20 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
after = html[end:composer_idx]
# Phase 104 (owner 2026-09-12): the ONE permitted child between the
# row and the composer is the hidden-by-default question-length
# counter — everything else (ids, buttons, sections, forms) is
# still excluded from the gap.
# counter; phase 123 (task 02, TODO L6) added the second — the
# attach preview strip (the selected image above the input row:
# thumbnail + filename + remove button), also `hidden` by default
# (the global [hidden] rule — zero height at rest, the
# pinned-cluster geometry untouched). Everything else (ids,
# buttons, sections, forms) is still excluded from the gap — the
# strip's own element block is stripped (like the counter) so the
# exclusions below stay meaningful.
strip_start = after.find('<div class="attach-preview"')
assert strip_start != -1, (
"the phase-123 attach preview strip sits above the composer"
)
strip_end = after.find("</div>", strip_start) + len("</div>")
after = after[:strip_start] + after[strip_end:]
after_minus_counter = after.replace(
'<p class="char-count" id="char-count" hidden></p>', ""
)
@@ -746,8 +758,9 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
and "<section" not in after_minus_counter
and "<form" not in after_minus_counter
), (
"nothing but the phase-104 counter + the composer comment lands "
"between the row and the composer"
"nothing but the phase-104 counter + the phase-123 attach "
"preview + the composer comment lands between the row and the "
"composer"
)
# Phase 76 (task 02): the folded view files are gone (the shell's
# chat view is the one and only carrier of the row — pinned above);
+17 -4
View File
@@ -29,7 +29,10 @@ from app.core.security_headers import CSP, SecurityHeadersMiddleware
#: The exact expected header set (decision A1 for the CSP, A4 for the
#: other two).
EXPECTED_HEADERS = {
"content-security-policy": "default-src 'self'; base-uri 'none'; frame-ancestors 'none'",
"content-security-policy": (
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
"img-src 'self' data:"
),
"x-frame-options": "DENY",
"x-content-type-options": "nosniff",
}
@@ -111,10 +114,19 @@ def _assert_security_headers(start: Message, expected_extra: dict[str, str] | No
def test_csp_constant_is_the_exact_a1_policy() -> None:
"""The owner-approved A1 string, verbatim: same-origin default, no
base-tag hijack, no framing — no 'unsafe-inline', no report sink."""
assert CSP == "default-src 'self'; base-uri 'none'; frame-ancestors 'none'"
"""The owner-approved A1 string (phase 82), verbatim, with the
phase-123 ``img-src`` carve-out (the question-image composer's
data-URL preview + live bubble — see ``security_headers.CSP``):
same-origin default, no base-tag hijack, no framing — no
'unsafe-inline', no report sink, and the ``data:`` allowance is
SCOPED to img-src (never script/style/fetch)."""
assert CSP == (
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
"img-src 'self' data:"
)
assert "unsafe-inline" not in CSP
# the carve-out is img-src ONLY — no other directive gains data:
assert CSP.count("data:") == 1
# ---------------------------------------------------------------------------
@@ -158,6 +170,7 @@ def test_pre_existing_csp_from_an_inner_layer_is_preserved() -> None:
while the other two headers are still added."""
themed = (
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
"img-src 'self' data:; "
"style-src 'self' 'sha256-2rm3wPcQfXmE8q1s9vBzK7hN4tY5uJ6gW3oR0cAeDfH='"
)
wrapped = SecurityHeadersMiddleware(
+7 -2
View File
@@ -101,10 +101,15 @@ def test_persisted_on_leave_flag_is_module_scoped_and_turn_reset() -> None:
# Reset at the top of the turn handler (runTurn — phase 49 extracted
# the turn from handleSend) — before the turn's fetch, where the
# other turn locals are initialized.
# other turn locals are initialized. The pin is the ORDER (resets
# before the fetch), not a char window: phase 123 task 02 grew the
# save-point-1 region above the resets (the attached image's bubble
# + record + strip clear) without moving the resets.
turn = js.find("async function runTurn")
assert turn != -1
top = js[turn : turn + 1500]
fetch_idx = js.find('fetch("/api/chat"', turn)
assert fetch_idx != -1
top = js[turn:fetch_idx]
assert "persistedOnLeave = false;" in top, (
"persistedOnLeave must be reset per turn, at the top of the turn handler"
)