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:
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` "
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user