The menu that never appeared, and the reason it never did
composer.js built its menu lazily inside show(), and refresh() wrote list.innerHTML before calling it. `list` is null until build() has run, so the first `/` or `@` ever typed threw a TypeError and took the handler with it. The menu has never appeared in any browser. That is why /compact "isn't there": nothing was. I shipped it having only run `node --check`, which parses the file happily. So this also brings the thing that catches it: a DOM stub driven under node -- not committed, hard rule 1 stands, it is an instrument like curl. It reproduced the crash in one run and immediately found two more: choosing a command from the menu left `/help` sitting in the box so the next Enter ran it again, and Tab completed nothing. Tab now completes and Enter runs, which is the split that matters for a command taking an argument. `.select--sm` was used three times and defined nowhere. I deleted the copy in chat.css and left a comment saying it "is defined once, in app.css", where it did not exist -- so those selects fell back to plain `.select`: width 100% in a flex row where four siblings wanted the same, all of them shrinking together until each was a few characters wide, and half a rem taller than everything beside them. That was the whole of "the connection switch needs to be wider". The connection and directory move to the topbar. They cannot change -- update_chat refuses both with a 409 -- so they are facts about the chat, of a kind with the Temporary badge, not controls on the message. The mode stays by the box. Compaction says it is working. It makes a model call that takes seconds and had no indicator anywhere: `hx-indicator` appears nowhere in this codebase, and the Generation.status channel that says "Summarising earlier messages…" for the automatic path cannot be borrowed, because it lives in the streaming bubble and this endpoint refuses to run while any message is unfinished. The overflow menu now runs the same code as /compact rather than posting for itself, so there is one implementation, one spinner, and one place the endpoint's four carefully written 409s finally reach somebody. /effort, low medium high, per chat with a per-model default. It goes out twice because there is no field that works everywhere: OpenAI and vLLM read reasoning_effort, llama.cpp's own docs say other values "have no effect" and its maintainer says the field "simply gets dropped without error or logging" -- what reaches gpt-oss behind it is chat_template_kwargs. Both are sent, and only once an effort has been chosen, so a provider strict about unknown parameters sees exactly the request it always did until somebody opts in. The control appears only on a model marked `reasoning`, a flag that has existed since the beginning with no reader at all. Mentions and recognised commands are marked as you type -- a mirror behind the textarea holding the same text with every character transparent, contributing nothing but a rounded rectangle, so a pixel of drift is a misplaced rectangle rather than a doubled glyph. A command is marked only when it resolves, so `/thoughts on this` visibly is not one before you send it. And again in the transcript, where user turns had no render step at all and now escape before they inject. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
"""Reasoning effort: what goes out, and what does not.
|
||||
|
||||
The second half matters as much as the first. There is no field that works
|
||||
everywhere -- OpenAI and vLLM read `reasoning_effort`, llama.cpp drops it
|
||||
silently and reads only `chat_template_kwargs` -- so both are sent. That is only
|
||||
safe because neither is sent at all until somebody chooses an effort, which is
|
||||
what keeps an endpoint strict about unknown parameters working exactly as it
|
||||
did.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import Chat, Connection, Model, User
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
|
||||
def _model(db, **capabilities) -> Model:
|
||||
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
model = Model(
|
||||
connection_id=connection.id,
|
||||
model_id="m",
|
||||
capabilities_json={"reasoning": True, **capabilities},
|
||||
)
|
||||
db.add(model)
|
||||
db.commit()
|
||||
return model
|
||||
|
||||
|
||||
def _chat(db, effort: str | None = None) -> Chat:
|
||||
model = _model(db)
|
||||
user = db.scalars(select(User)).first()
|
||||
chat = Chat(
|
||||
user_id=user.id,
|
||||
model_id=model.model_id,
|
||||
connection_id=model.connection_id,
|
||||
params_json={"reasoning_effort": effort} if effort is not None else {},
|
||||
)
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
return chat
|
||||
|
||||
|
||||
# --- What reaches the endpoint -----------------------------------------------
|
||||
def test_an_effort_goes_out_in_both_forms(client: TestClient, db, registered):
|
||||
"""One value, two fields. Neither endpoint family reads the other's."""
|
||||
chat = _chat(db, "high")
|
||||
|
||||
body = chat_service.build_request(db, chat)
|
||||
|
||||
assert body["reasoning_effort"] == "high"
|
||||
assert body["chat_template_kwargs"] == {"reasoning_effort": "high"}
|
||||
|
||||
|
||||
def test_no_effort_means_neither_field(client: TestClient, db, registered):
|
||||
"""The whole safety of sending both. A chat nobody has set an effort on is
|
||||
byte-for-byte the request it was before this existed, so a provider that
|
||||
refuses unknown parameters is untouched until somebody opts in."""
|
||||
chat = _chat(db)
|
||||
|
||||
body = chat_service.build_request(db, chat)
|
||||
|
||||
assert "reasoning_effort" not in body
|
||||
assert "chat_template_kwargs" not in body
|
||||
|
||||
|
||||
def test_a_cleared_effort_means_neither_field(client: TestClient, db, registered):
|
||||
"""Cleared is stored as None, like every other parameter here."""
|
||||
chat = _chat(db, None)
|
||||
|
||||
body = chat_service.build_request(db, chat)
|
||||
|
||||
assert "reasoning_effort" not in body
|
||||
|
||||
|
||||
@pytest.mark.parametrize("junk", ["sudo", "HIGH ", "maximum", "1"])
|
||||
def test_a_value_that_is_not_an_effort_is_not_sent(client: TestClient, db, registered, junk):
|
||||
"""Never trusted from the row: it could predate a change to the list."""
|
||||
chat = _chat(db, junk)
|
||||
|
||||
assert "reasoning_effort" not in chat_service.build_request(db, chat)
|
||||
|
||||
|
||||
def test_existing_chat_template_kwargs_are_kept(client: TestClient, db, registered):
|
||||
"""Merged rather than replaced, so a future caller setting something else
|
||||
there does not lose it."""
|
||||
body: dict = {"chat_template_kwargs": {"enable_thinking": True}}
|
||||
|
||||
chat_service.apply_effort(body, "low")
|
||||
|
||||
assert body["chat_template_kwargs"] == {"enable_thinking": True, "reasoning_effort": "low"}
|
||||
|
||||
|
||||
# --- Setting it ---------------------------------------------------------------
|
||||
def test_patching_the_effort_stores_it(client: TestClient, db, registered):
|
||||
chat = _chat(db)
|
||||
|
||||
assert client.patch(
|
||||
f"/api/chats/{chat.id}", data={"reasoning_effort": "medium"}
|
||||
).status_code == 204
|
||||
|
||||
db.refresh(chat)
|
||||
assert chat.params_json["reasoning_effort"] == "medium"
|
||||
|
||||
|
||||
def test_an_empty_effort_clears_it(client: TestClient, db, registered):
|
||||
chat = _chat(db, "high")
|
||||
|
||||
client.patch(f"/api/chats/{chat.id}", data={"reasoning_effort": ""})
|
||||
|
||||
db.refresh(chat)
|
||||
assert chat.params_json["reasoning_effort"] is None
|
||||
|
||||
|
||||
def test_an_unknown_effort_leaves_the_old_one(client: TestClient, db, registered):
|
||||
"""Ignored, not refused: a typo should not cost the setting you had."""
|
||||
chat = _chat(db, "low")
|
||||
|
||||
client.patch(f"/api/chats/{chat.id}", data={"reasoning_effort": "extreme"})
|
||||
|
||||
db.refresh(chat)
|
||||
assert chat.params_json["reasoning_effort"] == "low"
|
||||
|
||||
|
||||
def test_the_control_only_appears_on_a_reasoning_model(client: TestClient, db, registered):
|
||||
"""The flag has existed with no reader since the beginning; this is its
|
||||
first job. Offering the control everywhere would offer a setting that does
|
||||
nothing almost everywhere."""
|
||||
chat = _chat(db)
|
||||
assert "data-effort" in client.get(f"/chat/{chat.id}").text
|
||||
|
||||
model = db.scalars(select(Model)).one()
|
||||
model.capabilities_json = {"reasoning": False}
|
||||
db.commit()
|
||||
|
||||
assert "data-effort" not in client.get(f"/chat/{chat.id}").text
|
||||
|
||||
|
||||
# --- The per-model default ----------------------------------------------------
|
||||
def test_a_new_chat_starts_from_the_models_defaults(client: TestClient, db, registered):
|
||||
"""`Model.params_json` has claimed to do this since it was added and did it
|
||||
nowhere. It is empty on every existing row, so honouring it changes nothing
|
||||
until an administrator sets something."""
|
||||
model = _model(db)
|
||||
model.params_json = {"reasoning_effort": "high"}
|
||||
db.commit()
|
||||
|
||||
client.post("/api/chats/start", data={"content": "hello", "model_id": "m"})
|
||||
|
||||
chat = db.scalars(select(Chat)).one()
|
||||
assert chat.params_json["reasoning_effort"] == "high"
|
||||
|
||||
|
||||
def test_a_model_with_no_defaults_starts_a_plain_chat(client: TestClient, db, registered):
|
||||
_model(db)
|
||||
|
||||
client.post("/api/chats/start", data={"content": "hello", "model_id": "m"})
|
||||
|
||||
assert db.scalars(select(Chat)).one().params_json == {}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Marking `@mentions` in a message somebody wrote.
|
||||
|
||||
This is the one render path where a person controls the bytes exactly, and
|
||||
until now there was no render path at all -- the template printed the column
|
||||
and let `white-space: pre-wrap` carry the newlines. So the first half of every
|
||||
test here is that escaping still happens, and happens *before* anything is
|
||||
injected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from lembas.services.markdown import highlight_tokens
|
||||
|
||||
|
||||
def test_a_mention_is_marked():
|
||||
assert highlight_tokens("look at @src/main.py") == (
|
||||
'look at <span class="tok-mention">@src/main.py</span>'
|
||||
)
|
||||
|
||||
|
||||
def test_a_mention_at_the_start_is_marked():
|
||||
assert highlight_tokens("@README.md is wrong") == (
|
||||
'<span class="tok-mention">@README.md</span> is wrong'
|
||||
)
|
||||
|
||||
|
||||
def test_an_email_address_is_not_a_mention():
|
||||
"""The whole reason the pattern is anchored on whitespace. Without it every
|
||||
address in a message becomes a highlighted file reference."""
|
||||
assert "tok-mention" not in highlight_tokens("write to frodo@shire.test")
|
||||
|
||||
|
||||
def test_a_bare_at_is_left_alone():
|
||||
assert "tok-mention" not in highlight_tokens("dinner @ 8")
|
||||
|
||||
|
||||
def test_several_mentions_are_all_marked():
|
||||
marked = highlight_tokens("@a.py and @b.py")
|
||||
|
||||
assert marked.count("tok-mention") == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hostile",
|
||||
[
|
||||
"<script>alert(1)</script>",
|
||||
"@<script>alert(1)</script>",
|
||||
"</span><img src=x onerror=alert(1)>",
|
||||
"@a\"><script>alert(1)</script>",
|
||||
],
|
||||
)
|
||||
def test_markup_is_escaped_before_anything_is_injected(hostile):
|
||||
"""The order is the security property. Injecting first and escaping after
|
||||
would escape our own span; escaping first means the span is the only markup
|
||||
that can exist in the output."""
|
||||
out = highlight_tokens(hostile)
|
||||
|
||||
assert "<script" not in out
|
||||
assert "<img" not in out
|
||||
# The only tags in the result are ours.
|
||||
assert out.replace('<span class="tok-mention">', "").replace("</span>", "").count("<") == 0
|
||||
|
||||
|
||||
def test_an_ampersand_survives_as_an_entity():
|
||||
assert highlight_tokens("a & b") == "a & b"
|
||||
|
||||
|
||||
def test_newlines_are_untouched():
|
||||
"""They are carried by `white-space: pre-wrap`, not by markup. Turning them
|
||||
into <br> here would double up with the CSS."""
|
||||
assert highlight_tokens("one\ntwo") == "one\ntwo"
|
||||
|
||||
|
||||
def test_empty_is_empty():
|
||||
assert highlight_tokens("") == ""
|
||||
|
||||
|
||||
# --- Through the page ---------------------------------------------------------
|
||||
def _model(db):
|
||||
"""A model, without which index.html renders the "no models available"
|
||||
screen *instead of* the thread -- so an assertion about message markup
|
||||
would be made against a page that has no messages on it."""
|
||||
from lembas.db.models import Connection, Model
|
||||
|
||||
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
db.add(Model(connection_id=connection.id, model_id="m"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_a_sent_mention_is_marked_in_the_transcript(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
from lembas.db.models import ROLE_USER, Chat, Message
|
||||
|
||||
_model(db)
|
||||
chat_id = make_chat()
|
||||
chat = db.get(Chat, chat_id)
|
||||
db.add(Message(chat_id=chat.id, role=ROLE_USER, content="check @src/main.py please"))
|
||||
db.commit()
|
||||
|
||||
body = client.get(f"/chat/{chat_id}").text
|
||||
|
||||
assert '<span class="tok-mention">@src/main.py</span>' in body
|
||||
|
||||
|
||||
def test_copying_a_message_still_yields_what_was_typed(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
"""The hidden copy source stays raw. Copy must give back the text, not the
|
||||
markup wrapped round it."""
|
||||
from lembas.db.models import ROLE_USER, Chat, Message
|
||||
|
||||
_model(db)
|
||||
chat_id = make_chat()
|
||||
chat = db.get(Chat, chat_id)
|
||||
message = Message(chat_id=chat.id, role=ROLE_USER, content="check @src/main.py")
|
||||
db.add(message)
|
||||
db.commit()
|
||||
|
||||
body = client.get(f"/chat/{chat_id}").text
|
||||
source = body[body.index(f'id="msg-body-{message.id}"') :][:200]
|
||||
|
||||
assert "tok-mention" not in source
|
||||
assert "@src/main.py" in source
|
||||
Reference in New Issue
Block a user