Fix attachments never being sent with the message

Uploading an image showed the chip and then did nothing: the file was
stored but never reached the model.

Two causes, both in the composer template.

The chips live in #attachments, and each carries the hidden file_ids
input that binds it to the message. That container sat OUTSIDE the
<form>, with an `hx-include="#attachments"` on a hidden <div> inside the
form meant to pull it back in. That attribute only has an effect on the
element issuing the request -- on a child of it, it does nothing. So the
form serialised content and nothing else, and post_message saw no
file_ids at all. Fixed by putting #attachments inside the form, where
the inputs are submitted because they are in the form, rather than
because of an attribute that has to be wired correctly. The file input
stays outside, since inside it would submit an empty file part on every
message.

Second: /chat preselected models[0] rather than the model a new chat
would actually use. With a vision model set as the default and a
non-vision one first in the admin ordering, the composer showed the
wrong model, sent the wrong model, and told the user images *would* be
sent when they would not. It now resolves through default_model(), the
same path /start uses.

Every server-side test passed throughout, because the bug was entirely
in the wiring between template and browser. Added tests that serialise
the rendered form the way a browser does -- every named input inside
<form> -- and assert file_ids is among them and the image reaches the
model as a content part. Verified they fail with the old markup
restored, then pass again.

Confirmed end to end against gemma4-e4b-q8: given a drawing, it replied
"Left: Green Circle / Right: Orange Triangle".

220 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 14:08:52 +02:00
parent 085dca5ec4
commit f744232d25
6 changed files with 206 additions and 36 deletions
+95
View File
@@ -495,3 +495,98 @@ def test_deleting_a_message_deletes_its_attachments(client: TestClient, db, chat
client.delete(f"/api/chats/{chat_with_model}")
assert db.scalar(select(Attachment)) is None
# --- Composer wiring ---------------------------------------------------------
# These assert on the rendered HTML rather than on behaviour, because the bug
# they guard against lives entirely in the template: every server-side test
# passed while the browser silently never sent file_ids at all.
def test_the_attachments_container_is_inside_the_composer_form(
client: TestClient, db, chat_with_model
):
"""The chips carry the hidden file_ids inputs. Outside the form they are
not serialised, and hx-include does not help -- it only has an effect on
the element issuing the request, not on a child of it."""
import re
page = client.get(f"/chat/{chat_with_model}").text
form = re.search(r'<form class="composer__form".*?</form>', page, re.S)
assert form, "composer form not found"
assert 'id="attachments"' in form.group(0)
def test_the_file_input_is_outside_the_composer_form(client: TestClient, db, chat_with_model):
"""Inside, it would be submitted as an empty file part on every message."""
import re
page = client.get(f"/chat/{chat_with_model}").text
form = re.search(r'<form class="composer__form".*?</form>', page, re.S)
assert 'id="file-input"' not in form.group(0)
assert 'id="file-input"' in page
def test_the_new_chat_composer_also_contains_the_attachments(
client: TestClient, db, chat_with_model
):
import re
page = client.get("/chat").text
form = re.search(r'<form class="composer__form".*?</form>', page, re.S)
assert form and 'id="attachments"' in form.group(0)
def test_the_chip_carries_a_file_ids_input(client: TestClient, db, registered):
"""That input is the entire mechanism by which an upload reaches a message."""
response = client.post(
"/api/files", files={"file": ("a.txt", b"hi", "text/plain")}
)
assert 'name="file_ids"' in response.text
assert 'type="hidden"' in response.text
def test_a_browser_serialising_the_form_actually_sends_the_attachment(
client: TestClient, db, chat_with_model
):
"""End-to-end wiring check.
Uploads a file, splices the returned chip into the page exactly as the
browser does, then serialises the composer form the way a browser would --
every named input inside <form> -- and posts that. This is the test that
fails when the chips drift back outside the form.
"""
import re
chip = client.post(
"/api/files", files={"file": ("proof.png", png_bytes(), "image/png")}
).text
attachment = db.scalar(select(Attachment))
assert attachment.message_id is None
page = client.get(f"/chat/{chat_with_model}").text
form_html = re.search(r'<form class="composer__form".*?</form>', page, re.S).group(0)
# The chips are inserted into #attachments, which lives inside the form.
form_html = form_html.replace(
'<div class="composer__attachments" id="attachments"></div>',
f'<div class="composer__attachments" id="attachments">{chip}</div>',
)
fields: list[tuple[str, str]] = []
for tag in re.findall(r"<(?:input|textarea)\b[^>]*>", form_html):
name = re.search(r'name="([^"]+)"', tag)
if not name:
continue
value = re.search(r'value="([^"]*)"', tag)
fields.append((name.group(1), value.group(1) if value else ""))
assert ("file_ids", attachment.id) in fields, f"file_ids not serialised: {fields}"
client.post(f"/api/chats/{chat_with_model}/messages", data=dict(fields) | {"content": "look"})
db.refresh(attachment)
message = db.scalar(select(Message).where(Message.role == "user"))
assert attachment.message_id == message.id
chat = db.get(Chat, chat_with_model)
content = chat_service.build_request(db, chat)["messages"][0]["content"]
assert isinstance(content, list), "the image never reached the model"
assert any(p.get("type") == "image_url" for p in content)