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
+33
View File
@@ -1083,6 +1083,16 @@ _HTTPS_PER_DEAD_ATTEMPT = 3
#: re-drives the failure sequence from zero.
_fail_posts: dict[str, int] = {}
#: Phase 123 (task 04 — question images): a small ring buffer of the
#: recent ``/v1/chat/completions`` request bodies, exposed on the
#: ``/v1/e2e/captured`` pair below. Purely OBSERVATIONAL (the buffer
#: never influences an answer — determinism is untouched): it lets a
#: story suite assert on the EXACT request the app built — e.g. that a
#: question-image turn delivered the multimodal user content list
#: (text part + ``image_url`` data URL) to the model.
_CAPTURED: list[dict[str, Any]] = []
_CAPTURE_MAX = 100
def _llm_500(why: str) -> JSONResponse:
"""A dead-proxy 500 with a JSON error body (phase 67 injection)."""
@@ -2416,6 +2426,23 @@ def compose_thinking_paragraphs(body: dict[str, Any]) -> str:
return "\n".join(out)
@app.get("/v1/e2e/captured")
def e2e_captured() -> list[dict[str, Any]]:
"""Phase 123 (task 04): the recent chat-completions request bodies
(oldest first, capped at ``_CAPTURED_MAX``) — the mock's capture
for request-shape assertions (see ``_CAPTURED``)."""
return _CAPTURED
@app.post("/v1/e2e/captured/reset")
def e2e_captured_reset() -> dict[str, int]:
"""Phase 123 (task 04): clear the capture so a suite can assert on
exactly the requests of the turn it is about to drive (e.g. the
toggle-off rejection proves ZERO chat calls — an empty capture)."""
_CAPTURED.clear()
return {"cleared": True}
@app.post("/__shutdown__")
def shutdown() -> dict[str, Any]:
"""Test hook (loading-feedback story): terminate this mock process to
@@ -2619,6 +2646,12 @@ def _tool_call_stream(name: str, arguments: dict[str, Any], call_id: str) -> Any
@app.post("/v1/chat/completions")
def chat_completions(body: dict[str, Any]) -> Any:
# Phase 123 (task 04): the OBSERVATIONAL capture (the ring buffer —
# recorded before any flow decision, so a 500-injected request is
# captured too; the buffer never touches the answer path).
_CAPTURED.append(body)
if len(_CAPTURED) > _CAPTURE_MAX:
del _CAPTURED[: len(_CAPTURED) - _CAPTURE_MAX]
user_lower = _user(body).lower()
# Phase 37 (agent document tools): the deterministic marker flow.
# The app's chat path is the only streaming consumer of this mock, so
+626
View File
@@ -0,0 +1,626 @@
"""Phase 123 E2E (Playwright): chat image questions — attach an image to a
question (TODO L6).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov
The suite's app instance runs with ``BOR_IMAGES=true`` (module env
override — the conftest per-suite-app pattern, leak guards included;
the question-image store is a suite-private scratch dir so the app
under test never writes into the owner's real ``~/bor-sources``), and
a SECOND module app runs with ``BOR_IMAGES`` forced ``false`` (the
default contract — both apps pin the toggle EXPLICITLY, so an
operator's local ``.env`` cannot leak it in either direction).
The KB is deliberately NOT seeded (each test truncates it): the
question-image turn is a DETERMINISTIC deflection (no chunks → LOW
→ the mock's honest "I haven't done anything like that" answer) —
the deflected branch is a construction site for the multimodal user
message (pinned by ``app/api/chat.py``'s docstring), and the answer
the mock streams is independent of the image part (the mock's
``_content_text`` maps a part list to its text parts).
* attach a fixture PNG in the composer → the preview strip shows
(thumbnail ≤48px + the filename) → remove → the strip clears and
the file state is gone (a fresh pick re-renders in place);
* re-attach → send → exactly ONE upload, the user bubble shows the
image (the live data URL, alt = the filename), the preview strip
must not linger into the turn, the mock's (text-only) answer
streams, and — via the mock's capture (``/v1/e2e/captured``) —
the model RECEIVED the multimodal user content list (the text
part == the question + the ``image_url`` data URL that decodes to
the uploaded bytes);
* the saved record carries the stored PATH (A5: never base64 —
nothing base64 crosses the localStorage boundary);
* ``page.reload()`` → the user bubble restores WITH its image from
the stored path (the image route's request count confirms a PATH
fetch, not an inline data URL);
* share the chat → a fresh anonymous context opening the shared link
sees the user's image on the shared page (the serve route is
public — the shared view is faithful);
* default-off negative (the flag-off app): ``#attach-btn`` stays
hidden for good (the default-off contract) and a direct
``POST /api/chat`` with an ``image`` settles the hinted error
frame with ZERO model calls (the capture stays empty).
"""
from __future__ import annotations
import base64
import json
import os
import re
import subprocess
import sys
import time
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
REPO = Path(__file__).resolve().parents[2]
#: A real 1×1 transparent PNG (the unit/integration suites' fixture —
#: the pipeline is content-agnostic; the well-formed bytes keep the
#: upload + serve + render + capture pins honest).
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
)
PNG_NAME = "diagram.png"
QUESTION = "What is in this screenshot? (chat-images)"
TEXT_ONLY_QUESTION = "How is my Kubernetes cluster set up? (chat-images text-only)"
STORAGE_KEY = "bor.chat.v1"
DEFLECT_PHRASE = r"haven't done anything like that"
IMAGE_PATH_RE = re.compile(r"^/api/chat-images/[0-9a-f]{32}\.png$")
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
TURN_TIMEOUT_MS = 30_000
# ---------------------------------------------------------------------------
# Module apps: images ON (the story) and images forced OFF (the default
# contract). Each owns its port + its scratch question-image dir.
# ---------------------------------------------------------------------------
def _app_env(mock_llm: int, app_port: int, *, images: bool, scratch: Path) -> dict[str, str]:
"""The conftest app env (leak guards included) with the phase-123
knobs: ``BOR_IMAGES`` pinned EXPLICITLY (true for the story app,
false for the default app — process env ranks above an operator's
local gitignored ``.env``, so the contract under test cannot leak
in either direction) and the question-image store in the suite's
scratch dir (the app under test must not write into the owner's
real ``~/bor-sources``)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated gate (conftest pattern) — with an UNSEEDED KB
# (this suite's determinism) every turn is a deflection anyway;
# the pins keep the gate's quadrant stable if the dev KB leaks in.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
# Phase 67: instant retry waits + the code-default budget.
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# Leak guards (conftest pattern).
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(Settings.model_fields["suggestions"].default)
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
# Phase 123: the toggle under test + the suite-private image store.
env["BOR_IMAGES"] = "true" if images else "false"
env["BOR_CHAT_IMAGE_DIR"] = str(scratch / "chat-images")
return env
@pytest.fixture(scope="module")
def app_server(mock_llm: int, tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
"""The story app — ``BOR_IMAGES=true`` (the module env override;
the conftest session app is never started in this isolated run,
so no port clash)."""
scratch = tmp_path_factory.mktemp("bor_chat_image_on")
port = int(os.environ.get("E2E_APP_PORT_CHAT_IMAGES", "8160"))
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
cwd=REPO,
env=_app_env(mock_llm, port, images=True, scratch=scratch),
)
try:
_wait_http(f"http://127.0.0.1:{port}/api/health")
yield f"http://127.0.0.1:{port}"
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
@pytest.fixture(scope="module")
def default_app_server(
mock_llm: int, tmp_path_factory: pytest.TempPathFactory
) -> Iterator[str]:
"""The default-contract app — ``BOR_IMAGES=false`` (only the
negative test starts it)."""
scratch = tmp_path_factory.mktemp("bor_chat_image_off")
port = int(os.environ.get("E2E_APP_PORT_CHAT_IMAGES_OFF", "8161"))
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
cwd=REPO,
env=_app_env(mock_llm, port, images=False, scratch=scratch),
)
try:
_wait_http(f"http://127.0.0.1:{port}/api/health")
yield f"http://127.0.0.1:{port}"
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def default_app_url(default_app_server: str) -> str:
return default_app_server
def _reset_db(mock_port: int, seed: bool) -> None:
"""Truncate the KB (and the turn log) so every turn in this suite
is a deterministic deflection. ``seed`` is always False here — a
question image is a separate concern from document ingestion (it
is NEVER indexed as a document), so the suite never imports."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
db.commit()
assert seed is False
# ---------------------------------------------------------------------------
# Browser helpers (the composer's attach flow + the turn's settle)
# ---------------------------------------------------------------------------
def _png_file(tmp_path: Path) -> Path:
png = tmp_path / PNG_NAME
png.write_bytes(PNG_1X1)
return png
def _attach(page: Page, png: Path) -> None:
"""One file pick in the composer's HIDDEN file input (the
paperclip button's backend — the native picker is replaced by
``set_input_files``, the E2E's standard input simulation): the
preview strip must reveal with the data-URL thumbnail + name."""
page.set_input_files("#attach-file", str(png))
strip = page.locator("#attach-preview")
expect(strip).to_be_visible(timeout=TURN_TIMEOUT_MS)
expect(page.locator("#attach-preview .attach-preview-name")).to_have_text(PNG_NAME)
thumb = page.locator("#attach-preview img")
src = thumb.get_attribute("src") or ""
assert src.startswith("data:image/png;base64,"), "the thumbnail is the live data URL"
def _wait_deflected_turn(page: Page) -> None:
"""Wait until the (unseeded-KB) turn has fully settled — the
deflected answer streamed and the Send button is back (the
``done`` frame restored it)."""
bubble = page.locator(".msg.brain.is-deflected .bubble").first
bubble.wait_for(state="visible", timeout=TURN_TIMEOUT_MS)
expect(bubble).to_contain_text(
re.compile(DEFLECT_PHRASE, re.IGNORECASE), timeout=TURN_TIMEOUT_MS
)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
def _stored(page: Page) -> dict[str, Any] | None:
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
return json.loads(raw) if raw else None
def _admin_cookies(page: Page) -> dict[str, str]:
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert r.status_code == 200
return r.json()["chats"]
def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None:
return next((c for c in rows if c["title"] == title), None)
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
def _wait_saved_row(
app_url: str,
cookies: dict[str, str],
title: str,
messages: int = 2,
) -> dict[str, Any]:
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
A2 — so there is no status line to wait on)."""
deadline = time.monotonic() + 15
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
last = _find_row(_chats(app_url, cookies), title)
if last is not None and last["message_count"] >= messages:
return last
time.sleep(0.2)
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
def _delete_row(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _send_with_attachment(page: Page, png: Path) -> tuple[str, list[str]]:
"""Attach → type → send → wait for the deflected settle. Returns
(the record's stored image PATH, the upload request URLs)."""
uploads: list[str] = []
def _on_request(req: Any) -> None:
if req.method == "POST" and req.url.endswith("/api/chat-images"):
uploads.append(req.url)
page.on("request", _on_request)
_attach(page, png)
page.fill("#message-input", QUESTION)
page.click("#send-btn")
img = page.locator(".msg.user .msg-image").first
expect(img).to_be_visible(timeout=TURN_TIMEOUT_MS)
_wait_deflected_turn(page)
stored = _stored(page)
assert stored is not None, "the conversation must be persisted (save point 1)"
path = stored["messages"][0]["image"]
assert IMAGE_PATH_RE.fullmatch(path), f"the record must carry the stored PATH: {path!r}"
return path, uploads
# ---------------------------------------------------------------------------
# The mock's capture (phase 123, task 04 — the request the SERVER built)
# ---------------------------------------------------------------------------
def _mock_base(mock_llm: int) -> str:
return f"http://127.0.0.1:{mock_llm}"
def _reset_capture(mock_llm: int) -> None:
r = httpx.post(f"{_mock_base(mock_llm)}/v1/e2e/captured/reset", timeout=10)
assert r.status_code == 200
def _captured(mock_llm: int) -> list[dict[str, Any]]:
r = httpx.get(f"{_mock_base(mock_llm)}/v1/e2e/captured", timeout=10)
assert r.status_code == 200
return r.json()
# ---------------------------------------------------------------------------
# 1. Attach → preview → remove (the composer's draft state)
# ---------------------------------------------------------------------------
def test_attach_preview_shows_and_remove_clears(
page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
) -> None:
_reset_db(mock_llm, seed=False)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
# Flag on (the story app): the paperclip is revealed at boot, with
# its accessible name (the SVG is decorative).
btn = page.locator("#attach-btn")
expect(btn).to_be_visible()
expect(btn).to_have_attribute("aria-label", "Attach an image")
png = _png_file(tmp_path)
_attach(page, png)
# The strip: the data-URL thumbnail + the filename (the readable
# label) + the remove ✕ (its accessible name).
expect(page.locator("#attach-preview img")).to_be_visible()
remove = page.locator("#attach-remove")
expect(remove).to_be_visible()
expect(remove).to_have_attribute("aria-label", "Remove the attached image")
# Remove: the strip clears and the file state is GONE — a fresh
# pick re-renders the strip in place (the state was truly reset,
# not merely hidden under a stale one).
page.click("#attach-remove")
expect(page.locator("#attach-preview")).to_be_hidden()
_attach(page, png)
expect(page.locator("#attach-preview")).to_be_visible()
expect(page.locator("#attach-preview .attach-preview-name")).to_have_text(PNG_NAME)
# ---------------------------------------------------------------------------
# 2. Send with an attachment: one upload, the image in the bubble, the
# multimodal request at the mock, the PATH (never base64) in storage
# ---------------------------------------------------------------------------
def test_send_with_attached_image_delivers_the_multimodal_request(
page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
) -> None:
_reset_db(mock_llm, seed=False)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
png = _png_file(tmp_path)
_reset_capture(mock_llm) # exactly the requests of THIS turn
path, uploads = _send_with_attachment(page, png)
# A8's ordering at the wire level: EXACTLY one upload (the double-
# fire guard never let a second through) and it preceded the turn.
assert len(uploads) == 1
# The live user bubble: the data URL (no fetch), alt = the
# filename; the preview strip must not linger into the turn.
img = page.locator(".msg.user .msg-image").first
src = img.get_attribute("src") or ""
assert src.startswith("data:image/png;base64,"), "the live bubble uses the data URL"
assert base64.b64decode(src.split(",", 1)[1]) == PNG_1X1
assert img.get_attribute("alt") == PNG_NAME
expect(page.locator("#attach-preview")).to_be_hidden()
# The mock (text-only) answer streamed normally (the mock ignores
# the image part) — and the REQUEST it received is the multimodal
# user content list: text part == the question + the image_url
# data URL that decodes to the uploaded bytes (the server built
# it from the stored file + the phase-122 mime map).
captured = _captured(mock_llm)
assert len(captured) == 1, "the deflected turn is exactly one model request"
user = captured[0]["messages"][-1]
assert user["role"] == "user"
assert user["content"] == [
{"type": "text", "text": QUESTION},
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode('ascii')}"
},
},
]
# A5 at the storage boundary: the record carries the PATH, and
# NOTHING base64 crossed into the localStorage payload.
stored = _stored(page)
assert stored is not None
assert stored["messages"][0]["image"] == path
assert "base64" not in json.dumps(stored)
# Cleanup: drop the auto-saved row (the dev DB is shared).
cookies = _admin_cookies(page)
row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION))
_delete_row(app_url, cookies, row["id"])
# ---------------------------------------------------------------------------
# 3. Reload: the user bubble restores WITH its image (from the stored
# path — the image route's request count confirms the path fetch)
# ---------------------------------------------------------------------------
def test_reload_restores_the_image_from_the_stored_path(
page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
) -> None:
_reset_db(mock_llm, seed=False)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
png = _png_file(tmp_path)
path, _uploads = _send_with_attachment(page, png)
# Track the image route's fetches from here on — the restored
# bubble must load the image by FETCHING the stored path (not an
# inline data URL).
fetches: list[str] = []
def _on_request(req: Any) -> None:
if "/api/chat-images/" in req.url:
fetches.append(req.url)
page.on("request", _on_request)
page.reload()
expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000)
# The restored user bubble carries the image — its src is the
# STORED PATH (the record's key), not the live data URL.
img = page.locator(".msg.user .msg-image").first
expect(img).to_be_visible(timeout=30_000)
assert (img.get_attribute("src") or "") == path, "the restore renders from the stored path"
# The path fetch must actually fire (the lazy img loading it) —
# poll with a deadline. ``wait_for_timeout`` (not ``time.sleep``)
# is the tick: the sync API dispatches the ``request`` events
# queued during it, and a bare sleep would starve the listener.
deadline = time.monotonic() + 10
while not any(u.rstrip("/").endswith(path) for u in fetches):
if time.monotonic() > deadline:
raise AssertionError(
f"no fetch of the stored path ({len(fetches)} image requests: {fetches!r})"
)
page.wait_for_timeout(100)
# The rest of the conversation is unchanged.
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.user .bubble")).to_contain_text(QUESTION)
expect(page.locator(".msg.brain .bubble")).to_have_count(1)
expect(page.locator(".msg.brain .bubble")).to_contain_text(
re.compile(DEFLECT_PHRASE, re.IGNORECASE)
)
# Cleanup.
cookies = _admin_cookies(page)
row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION))
_delete_row(app_url, cookies, row["id"])
# ---------------------------------------------------------------------------
# 4. Share: a fresh anonymous context sees the user's image on the
# shared page (the public serve route — the shared view is faithful)
# ---------------------------------------------------------------------------
def test_shared_page_shows_the_user_image(
page: Page, browser, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
) -> None:
_reset_db(mock_llm, seed=False)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
png = _png_file(tmp_path)
path, _uploads = _send_with_attachment(page, png)
cookies = _admin_cookies(page)
row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION))
# The saved row's user record carries the PATH (the share's source
# of truth — A5: never base64).
detail = httpx.get(f"{app_url}/api/chats/{row['id']}", timeout=10, cookies=cookies)
assert detail.status_code == 200
saved_user = detail.json()["messages"][0]
assert saved_user["image"] == path
# Share (the chat page's pill — the owner-locked one action):
# grant the clipboard so the copy path runs (the status line is
# the assertion surface).
page.context.grant_permissions(
["clipboard-read", "clipboard-write"], origin=app_url
)
page.locator("#share-chat-btn").click()
expect(page.locator("#send-status")).to_have_text("Share link copied.", timeout=15_000)
row = _find_row(_chats(app_url, cookies), _auto_title(QUESTION))
assert row is not None and row.get("share_url")
share_url: str = row["share_url"]
assert SHARE_URL_RE.fullmatch(share_url)
try:
# A FRESH context (no cookies, no localStorage): the shared
# page renders the user's image (public route, same bubble
# treatment — alt = the record's text).
anon = browser.new_context()
try:
anon_page = anon.new_page()
anon_page.goto(app_url + share_url)
anon_img = anon_page.locator(".msg.user .msg-image").first
expect(anon_img).to_be_visible(timeout=30_000)
assert (anon_img.get_attribute("src") or "") == path
# The answer text is there too (the shared view is the
# full conversation, read-only).
expect(anon_page.locator(".msg.brain .bubble")).to_contain_text(
re.compile(DEFLECT_PHRASE, re.IGNORECASE)
)
finally:
anon.close()
finally:
_delete_row(app_url, cookies, row["id"])
# ---------------------------------------------------------------------------
# 5. Default-off negative: the control stays hidden; an API request
# with an image gets the hinted error frame, with NO model call
# ---------------------------------------------------------------------------
def _post_chat_sse(
app_url: str, cookies: dict[str, str], body: dict[str, Any]
) -> list[dict[str, Any]]:
"""``POST /api/chat`` straight from the test process (the hand-
crafted request the absent composer control would otherwise make)."""
frames: list[dict[str, Any]] = []
with httpx.stream(
"POST", f"{app_url}/api/chat", json=body, cookies=cookies, timeout=30
) as r:
assert r.status_code == 200, r.read()
buf = ""
for part in r.iter_text():
buf += part
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
frame = frame.strip()
if frame.startswith("data:"):
frames.append(json.loads(frame.removeprefix("data:").strip()))
return frames
def test_flag_off_hides_the_control_and_rejects_the_request(
page: Page,
default_app_url: str,
mock_llm: int,
db_ready: None,
) -> None:
_reset_db(mock_llm, seed=False)
page.set_default_timeout(30_000)
login(page, default_app_url, next="/")
# The config says images off — and the control stays hidden for
# GOOD (the default-off contract: the static markup ships hidden,
# the reveal gate never fires, the rendered DOM is pre-phase).
cfg = httpx.get(f"{default_app_url}/api/config", timeout=10).json()
assert cfg["images"] is False
expect(page.locator("#attach-btn")).to_be_hidden()
expect(page.locator("#attach-file")).to_be_hidden()
expect(page.locator("#attach-preview")).to_be_hidden()
# The server-side contract (the API is the authority — a hand-
# crafted request with an image): the phase-114 error frame with
# the EXACT detail + hint, ONE terminal frame (no ``done``), and
# ZERO model calls (the capture stays empty — the embed never ran).
_reset_capture(mock_llm)
cookies = _admin_cookies(page)
frames = _post_chat_sse(
default_app_url,
cookies,
{"message": "What is in this image?", "image": "/api/chat-images/" + "b" * 32 + ".png"},
)
assert [f["type"] for f in frames] == ["error"]
assert frames[0] == {
"type": "error",
"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.",
}
assert _captured(mock_llm) == [] # no model call (the rejected turn)
+7 -2
View File
@@ -33,8 +33,13 @@ import re
from playwright.sync_api import ConsoleMessage, Page, expect
#: The exact owner-approved policy (phase 82, decision A1).
CSP = "default-src 'self'; base-uri 'none'; frame-ancestors 'none'"
#: The exact owner-approved policy (phase 82, decision A1), with the
#: phase-123 img-src carve-out (the question-image composer's data-URL
#: preview + live bubble — ``data:`` is allowed for images ONLY).
CSP = (
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
"img-src 'self' data:"
)
#: Chromium reports CSP denials to the console with this phrasing
#: ("Refused to … because it violates the following Content Security
+310
View File
@@ -11,7 +11,9 @@ Requires: podman compose up -d db
from __future__ import annotations
import asyncio
import base64
import hashlib
import io
import json
import logging
import math
@@ -29,6 +31,7 @@ from sqlalchemy import delete, func, select, text
from sqlalchemy.orm import Session
from app.api import chat as chat_api
from app.api import chat_images
from app.config import Settings, get_settings
from app.main import app as fastapi_app
from app.models import Chunk, Document, GitSource, QueryLog
@@ -2197,3 +2200,310 @@ def test_text_only_grounded_turn_frame_has_no_image_url_key_anywhere(
]
for ref in done["sources"] + done["related"]:
assert set(ref) == {"source", "path", "title"} # the pre-122 key set
# ---------------------------------------------------------------------------
# Phase 123 (task 01): image questions — the upload → chat flow delivers
# the multimodal user message to the model (both construction sites), the
# toggle-off / stale frames settle before any model call (no record),
# text-only turns stay byte-identical, and the saved/shared chat round-
# trips the stored PATH (never base64 — LOCKED A5).
# ---------------------------------------------------------------------------
#: A real 1×1 transparent PNG (the phase-122 fixture bytes) — the server
#: is content-agnostic (the extension + size gates only), but a well-
#: formed fixture keeps the data-URL pin honest.
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
)
@pytest.fixture()
def chat_image_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point the question-image store (the upload/serve pair) AND the
turn pipeline at a tmp dir, with the ``images`` toggle ON — the
env-calibrated settings (the conftest mock thresholds) plus the
phase-123 pair (``images`` + ``chat_image_dir``); the monkeypatch
fixture reverts both patches."""
store = tmp_path / "chat-images"
store.mkdir()
settings = Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
images=True,
chat_image_dir=str(store),
)
monkeypatch.setattr(chat_images, "get_settings", lambda: settings)
monkeypatch.setattr(chat_api, "get_settings", lambda: settings)
return store
def _upload_image(client: TestClient, name: str = "screenshot.png") -> str:
"""One question-image upload (the composer's step before send) —
returns the served path (the value the chat request accepts)."""
r = client.post(
"/api/chat-images",
files={"file": (name, io.BytesIO(PNG_1X1), "image/png")},
)
assert r.status_code == 200, r.text
return r.json()["path"]
def _stream_chat_with(
client: TestClient, body: dict[str, Any]
) -> tuple[int, str, list[dict[str, Any]]]:
"""``_stream_chat`` with an arbitrary body (the ``image`` key)."""
with client.stream("POST", "/api/chat", json=body) as r:
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/event-stream")
buf = ""
frames: list[dict[str, Any]] = []
for part in r.iter_text():
buf += part
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
frame = frame.strip()
if frame.startswith("data:"):
frames.append(json.loads(frame.removeprefix("data:").strip()))
assert buf.strip() == "", "stream must end on a frame boundary"
return r.status_code, r.headers["content-type"], frames
def _expected_image_content(question: str) -> list[dict[str, Any]]:
"""The multimodal user content list the model must receive for a
question that carried the fixture image (the text part + the
image_url part — a data URL built server-side from the stored bytes
+ the phase-122 mime map)."""
return [
{"type": "text", "text": question},
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode('ascii')}"
},
},
]
def test_image_turn_delivers_the_multimodal_user_message(
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path
) -> None:
"""The full flow (LOCKED A5): the client uploads FIRST, then the
turn request carries the returned path — and the GROUNDED branch
(construction site 2: ``run_agent`` builds its own ``[system,
*history, user]`` from the value chat.py passes — the pinned flow)
delivers the multimodal content list to the model: the text part +
the image_url data URL that decodes to the stored bytes."""
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
path = _upload_image(client)
_, _, frames = _stream_chat_with(
client, {"message": QUESTION, "image": path}
)
finally:
fastapi_app.dependency_overrides.clear()
assert frames[-1]["type"] == "done"
assert frames[-1]["deflected"] is False
assert len(seeded_kb.seen_messages) == 1
assert seeded_kb.seen_messages[0][-1] == {
"role": "user",
"content": _expected_image_content(QUESTION),
}
# the data URL really is the stored bytes (decode + compare)
url = seeded_kb.seen_messages[0][-1]["content"][1]["image_url"]["url"]
assert base64.b64decode(url.split(",", 1)[1]) == PNG_1X1
def test_deflected_image_turn_delivers_the_multimodal_user_message(
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path
) -> None:
"""Construction site 1 (the deflected branch consumes chat.py's
``messages`` list directly): an off-topic question + image still
delivers the SAME multimodal list — the LOW prompt path never
drops the attachment."""
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
path = _upload_image(client)
_, _, frames = _stream_chat_with(
client, {"message": OFF_TOPIC, "image": path}
)
finally:
fastapi_app.dependency_overrides.clear()
assert frames[-1]["type"] == "done"
assert frames[-1]["deflected"] is True
assert len(seeded_kb.seen_messages) == 1
assert seeded_kb.seen_messages[0][-1] == {
"role": "user",
"content": _expected_image_content(OFF_TOPIC),
}
def test_image_turn_with_toggle_off_yields_the_hinted_frame_and_calls_nothing(
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The ``BOR_IMAGES`` gate: off with an image set → the phase-114
error frame with the EXACT detail + hint (ONE terminal frame — no
``done``), and NO model call (zero embeds, zero requests) and NO
record (the rejected turn saves nothing — the existing error-path
convention)."""
monkeypatch.setattr(
chat_api,
"get_settings",
lambda: Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
images=False,
chat_image_dir=str(chat_image_store),
),
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
path = _upload_image(client)
_, _, frames = _stream_chat_with(
client, {"message": "What is in this image?", "image": path}
)
finally:
fastapi_app.dependency_overrides.clear()
assert [f["type"] for f in frames] == ["error"]
assert frames[0] == {
"type": "error",
"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.",
}
# NO model call: the QUESTION embed never ran (``question_embeds``
# counts ``embed_one`` calls — the import's batch embeds are a
# different counter) and the answer endpoint was never asked.
assert seeded_kb.question_embeds == []
assert seeded_kb.seen_messages == []
assert db.scalars(select(QueryLog)).all() == [] # NO query_log row
def test_image_turn_with_a_missing_stored_file_yields_the_stale_frame(
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path
) -> None:
"""The stale-path edge (the file was deleted out-of-band): the SAME
frame shape with the "no longer available" detail and NO hint (the
banner's default copy is the honest fallback) — again before any
model call, no record."""
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
stale = "/api/chat-images/" + "e" * 32 + ".png" # well-formed, absent
_, _, frames = _stream_chat_with(
client, {"message": "What is in this image?", "image": stale}
)
finally:
fastapi_app.dependency_overrides.clear()
assert [f["type"] for f in frames] == ["error"]
assert frames[0] == {
"type": "error",
"detail": "That image is no longer available.",
"hint": None,
}
assert seeded_kb.question_embeds == [] # NO model call (see the toggle-off test)
assert seeded_kb.seen_messages == []
assert db.scalars(select(QueryLog)).all() == []
def test_text_only_turn_is_byte_identical_with_images_enabled(
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path
) -> None:
"""``image=None`` with the toggle ON: the user message is the PLAIN
STRING (not a content list) — the multimodal branch is inert, and
the turn behaves exactly as pre-phase (done frame, the question
embedded whole)."""
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
_, _, frames = _stream_chat_with(client, {"message": QUESTION})
finally:
fastapi_app.dependency_overrides.clear()
assert frames[-1]["type"] == "done"
assert seeded_kb.seen_messages[0][-1] == {"role": "user", "content": QUESTION}
assert isinstance(seeded_kb.seen_messages[0][-1]["content"], str)
assert seeded_kb.question_embeds == [QUESTION[: 1200]]
def test_saved_chat_round_trips_the_user_image_path(
client, db, chat_image_store: Path
) -> None:
"""Persistence (LOCKED A5): the user record carries the stored
PATH — the stored JSONB and the saved-chat response round-trip it,
the brain record carries NO image key, and NOTHING base64 crosses
the storage boundary. A text-only record stays without the key
(the phase-50 byte-identical contract for text-only chats)."""
db.execute(text("TRUNCATE saved_chats"))
db.commit()
try:
path = _upload_image(client)
r = client.post(
"/api/chats",
json={
"messages": [
{"who": "user", "text": "What is in this image?", "image": path},
{"who": "brain", "text": "A cat, by the look of it."},
]
},
)
assert r.status_code == 201, r.text
body = r.json()
assert body["messages"][0]["image"] == path
assert "image" not in body["messages"][1] # brain records never carry it
# The RAW stored JSONB: the path, never base64 (LOCKED A5)
raw = db.execute(
text("SELECT messages FROM saved_chats WHERE id = :id"),
{"id": body["id"]},
).scalar_one()
assert raw[0]["image"] == path
assert "base64" not in json.dumps(raw)
# The admin GET round-trips it losslessly
got = client.get(f"/api/chats/{body['id']}")
assert got.status_code == 200
assert got.json()["messages"][0]["image"] == path
assert "image" not in got.json()["messages"][1]
finally:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
def test_shared_chat_serves_the_user_image_path(
client, db, chat_image_store: Path
) -> None:
"""The shared view is faithful: the public snapshot carries the
user's image path (the public ``messages`` shape already includes
the optional key), and the image bytes themselves are publicly
servable (the image is part of the chat's content — phase 55 A1)."""
db.execute(text("TRUNCATE saved_chats"))
db.commit()
try:
path = _upload_image(client)
r = client.post(
"/api/chats",
json={
"share": True,
"messages": [
{"who": "user", "text": "What is in this image?", "image": path},
{"who": "brain", "text": "A cat, by the look of it."},
],
},
)
assert r.status_code == 201, r.text
share_url = r.json()["share_url"]
token = share_url.rsplit("/", 1)[-1]
anon = TestClient(fastapi_app)
got = anon.get(f"/api/shared/{token}")
assert got.status_code == 200
messages = got.json()["messages"]
assert messages[0]["image"] == path
assert "image" not in messages[1]
img = anon.get(path)
assert img.status_code == 200
assert img.content == PNG_1X1
finally:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
+100
View File
@@ -24,6 +24,7 @@ Requires: podman compose up -d db
"""
from __future__ import annotations
import json
import re
import time
import uuid
@@ -799,6 +800,105 @@ def test_public_read_returns_snapshot_without_private_keys(
assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN])
# ---------------------------------------------------------------------------
# Phase 123 (tasks 03/04): the question's attached image — the saved
# and shared shape carries the stored PATH (LOCKED A5: never base64),
# on the USER record only (the attachment belongs to the question — a
# brain record never carries the key). Text-only records stay WITHOUT
# the key (absent, never null — the phase-50 byte-identical contract,
# pinned by ``_expect`` above; the ``ChatMessage`` wrap serializer does
# the dropping, so every surface — the stored JSONB, the admin GET,
# the public shared read — inherits it).
# ---------------------------------------------------------------------------
#: A well-formed stored path (the ``POST /api/chat-images`` response's
#: shape — the chat-images router names the file
#: ``<uuid4().hex>.<ext>``; the API boundary itself only bounds the
#: string to 500 chars, the pattern gate is the chat request's).
IMAGE_PATH = "/api/chat-images/" + "a" * 32 + ".png"
def _user_with_image(text: str) -> dict[str, Any]:
return {"who": "user", "text": text, "image": IMAGE_PATH}
def test_create_round_trips_the_user_image_path(
admin_client: TestClient, db: Session
) -> None:
"""The user record carries the PATH at every boundary: the 201
body, the RAW stored JSONB (never base64 — the A5 contract), and
the admin GET — and the brain record stays WITHOUT the key.
A text-only record in the SAME chat stays key-free too."""
r = admin_client.post(
"/api/chats",
json={
"messages": [
_user(FIRST_QUESTION), # text-only — stays WITHOUT the key
_user_with_image("What is in this image?"),
{"who": "brain", "text": "A cat, by the look of it."},
]
},
)
assert r.status_code == 201, r.text
body = r.json()
assert "image" not in body["messages"][0]
assert body["messages"][1]["image"] == IMAGE_PATH
assert "image" not in body["messages"][2]
# The RAW stored JSONB (the DB is the durable boundary — the
# served bodies could in principle re-derive it): the path, and
# NOTHING base64 anywhere in the payload.
raw = db.execute(
text("SELECT messages FROM saved_chats WHERE id = :id"),
{"id": body["id"]},
).scalar_one()
assert "image" not in raw[0]
assert raw[1]["image"] == IMAGE_PATH
assert "image" not in raw[2]
assert "base64" not in json.dumps(raw)
got = admin_client.get(f"/api/chats/{body['id']}")
assert got.status_code == 200
assert "image" not in got.json()["messages"][0]
assert got.json()["messages"][1]["image"] == IMAGE_PATH
assert "image" not in got.json()["messages"][2]
def test_shared_serve_includes_the_user_image_path(
admin_client: TestClient,
) -> None:
"""The shared view is faithful (task 03's pin): the PUBLIC
snapshot's user record carries the image path — the public
``messages`` shape already gains the one optional key, so no new
shared-shape field exists — and the brain / text-only records
stay WITHOUT it. The image bytes themselves ride the public
serve route (phase 55 A1 — the image is part of the chat's
content, the token is the credential): pinned in
``test_chat_api.py`` alongside the upload."""
r = admin_client.post(
"/api/chats",
json={
"messages": [
_user(FIRST_QUESTION), # text-only — stays WITHOUT the key
_user_with_image("What is in this image?"),
{"who": "brain", "text": "A cat, by the look of it."},
]
},
)
assert r.status_code == 201, r.text
share_url = _share(admin_client, r.json()["id"])["share_url"]
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
got = anon.get(f"/api{share_url}")
assert got.status_code == 200
body = got.json()
assert set(body) == SHARED_OUT_KEYS # no new shared-shape field
messages = body["messages"]
assert "image" not in messages[0]
assert messages[1]["image"] == IMAGE_PATH
assert "image" not in messages[2]
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
admin_client: TestClient,
) -> None:
+6 -4
View File
@@ -8,7 +8,11 @@ API JSON, static assets, even the static catch-all's 404s — carries:
* ``Content-Security-Policy`` — the exact A1 string (``default-src 'self';
base-uri 'none'; frame-ancestors 'none'`` → clickjacking closed, no
inline anything because the No-CDN frontend has none);
inline anything because the No-CDN frontend has none), extended by
the phase-123 ``img-src 'self' data:`` carve-out (the question-image
composer's data-URL preview + live bubble — see ``CSP`` in
``app/core/security_headers.py``; the ``data:`` allowance is scoped
to img-src only);
* ``X-Frame-Options: DENY`` — the legacy no-framing fallback;
* ``X-Content-Type-Options: nosniff`` — the MIME-confusion belt.
@@ -69,9 +73,7 @@ def test_page_carries_all_three_headers(client: TestClient, db: Session) -> None
response = client.get("/")
assert response.status_code == 200
_assert_security_headers(response)
assert response.headers["content-security-policy"] == (
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'"
)
assert response.headers["content-security-policy"] == CSP
def test_api_health_carries_all_three_headers(client: TestClient) -> None:
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"
)