A folder that carries something, and a way to name one

A folder was a name and nothing else -- and not even that, since PATCH could
rename one and nothing in the interface ever called it. It now carries a
description, a system prompt, and seeds for the model, the kind and the agent
target, with a settings page behind the row.

The prompt is a fourth rung on the ladder, chat > folder > model > instance,
and it goes above the model deliberately: a model's prompt describes the model
wherever it is used, a folder's describes this piece of work whichever model
is pointed at it. It is read when a reply is built rather than copied when a
chat is made, so editing it reaches the chats already there, and the walk up
the parents is bounded and cycle-safe because it runs on the request path.
`api/pages.py` mirrors the ladder for the settings panel and had to gain the
same rung -- a panel naming the wrong source is worse than one naming none,
because it is believed.

The seeds fill in what the request left empty and nothing it filled in: the
folder says what this work usually needs, the screen in front of somebody says
what they want this time. `ssh_profile_id` is a plain string rather than a
foreign key, for the reason `compacted_through_id` is, so it is validated on
read.

Getting *into* a folder needed fixing too. `/api/chats/start` has accepted a
folder_id since folders existed and nothing ever sent one, so the only route in
was to make the chat elsewhere and move it. There is a New chat here on the row
now, and `?folder=` on the new-chat screen.

Naming is a themed dialog, and deliberately not htmx's hx-prompt: htmx calls
the browser's prompt() synchronously and only then fires htmx:prompt with the
answer already in hand, so intercepting the event cannot supply a different one
and the grey box appears anyway. `data-prompt` follows the data-confirm-button
shape instead -- swallow the click, ask, write the answer into hx-vals,
click again behind a guard. JSON.stringify rather than concatenation, or a
folder called `"` produces hx-vals that does not parse and the rename silently
does nothing. Driven under a DOM stub, and there is a test that no template
brings hx-prompt back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 08:30:49 +02:00
parent 9c61e40662
commit b602657450
12 changed files with 821 additions and 16 deletions
+311
View File
@@ -0,0 +1,311 @@
"""What a folder carries, and what the chats inside it inherit from it."""
from __future__ import annotations
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import Chat, Connection, Folder, Model, SshProfile, User
from lembas.services import chat as chat_service
from lembas.services import settings_store
from lembas.services.crypto import encrypt
def _add_connection(db) -> Connection:
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="test-model"))
db.commit()
return connection
def _folder(db, name: str, parent: Folder | None = None, **fields) -> Folder:
user = db.scalars(select(User).order_by(User.created_at)).first()
folder = Folder(
user_id=user.id, name=name, parent_id=parent.id if parent else None, **fields
)
db.add(folder)
db.commit()
return folder
# --- Naming and renaming -------------------------------------------------------
def test_a_folder_is_created_with_the_name_that_was_asked_for(
client: TestClient, db, registered
):
client.post("/api/folders", data={"name": "Isengard"})
assert db.scalar(select(Folder)).name == "Isengard"
def test_a_folder_with_no_name_still_gets_one(client: TestClient, db, registered):
"""The button asks first, but a request that arrives without one must not
produce a folder with a blank label nobody can click."""
client.post("/api/folders", data={})
assert db.scalar(select(Folder)).name == "New folder"
def test_renaming_a_folder(client: TestClient, db, registered):
"""PATCH has been able to do this since folders existed and nothing in the
interface called it, so a folder could not be renamed at all."""
folder = _folder(db, "Isengard")
assert client.patch(f"/api/folders/{folder.id}", data={"name": "Orthanc"}).status_code == 204
db.refresh(folder)
assert folder.name == "Orthanc"
def test_a_blank_rename_is_ignored(client: TestClient, db, registered):
"""A folder nobody can see the name of is one nobody can find."""
folder = _folder(db, "Isengard")
client.patch(f"/api/folders/{folder.id}", data={"name": " "})
db.refresh(folder)
assert folder.name == "Isengard"
# --- The system prompt ladder ---------------------------------------------------
def test_a_folder_prompt_reaches_a_chat_inside_it(client: TestClient, db, registered, make_chat):
_add_connection(db)
folder = _folder(db, "Errands", system_prompt="Answer in the fewest words.")
chat = db.get(Chat, make_chat())
chat.folder_id = folder.id
db.commit()
assert chat_service.effective_system_prompt(db, chat) == "Answer in the fewest words."
def test_a_nested_folder_inherits_its_parents_prompt(
client: TestClient, db, registered, make_chat
):
"""A project's prompt belongs on the project, not on each sub-folder of it."""
_add_connection(db)
top = _folder(db, "Project", system_prompt="Answer in the fewest words.")
inner = _folder(db, "Notes", parent=top)
chat = db.get(Chat, make_chat())
chat.folder_id = inner.id
db.commit()
assert chat_service.effective_system_prompt(db, chat) == "Answer in the fewest words."
def test_the_nearest_folder_prompt_wins(client: TestClient, db, registered, make_chat):
_add_connection(db)
top = _folder(db, "Project", system_prompt="From the top.")
inner = _folder(db, "Notes", parent=top, system_prompt="From the sub-folder.")
chat = db.get(Chat, make_chat())
chat.folder_id = inner.id
db.commit()
assert chat_service.effective_system_prompt(db, chat) == "From the sub-folder."
def test_the_chats_own_prompt_still_wins(client: TestClient, db, registered, make_chat):
_add_connection(db)
folder = _folder(db, "Errands", system_prompt="From the folder.")
chat = db.get(Chat, make_chat())
chat.folder_id = folder.id
chat.system_prompt = "From the chat."
db.commit()
assert chat_service.effective_system_prompt(db, chat) == "From the chat."
def test_a_folder_prompt_beats_the_models(client: TestClient, db, registered, make_chat):
"""The folder is the more specific statement: a model's prompt describes the
model wherever it is used, a folder's describes this piece of work."""
_add_connection(db)
model = db.scalar(select(Model))
model.system_prompt = "From the model."
folder = _folder(db, "Errands", system_prompt="From the folder.")
chat = db.get(Chat, make_chat())
chat.folder_id = folder.id
db.commit()
assert chat_service.effective_system_prompt(db, chat) == "From the folder."
def test_an_empty_folder_prompt_falls_through(client: TestClient, db, registered, make_chat):
_add_connection(db)
model = db.scalar(select(Model))
model.system_prompt = "From the model."
folder = _folder(db, "Errands")
chat = db.get(Chat, make_chat())
chat.folder_id = folder.id
db.commit()
assert chat_service.effective_system_prompt(db, chat) == "From the model."
def test_the_panel_names_the_folder_as_the_source(client: TestClient, db, registered, make_chat):
"""The settings panel mirrors the ladder and has to keep mirroring it. One
naming the wrong source is worse than one naming none, because it is
believed."""
_add_connection(db)
folder = _folder(db, "Errands", system_prompt="Answer in the fewest words.")
chat_id = make_chat()
chat = db.get(Chat, chat_id)
chat.folder_id = folder.id
db.commit()
page = client.get(f"/chat/{chat_id}").text
assert "folder prompt" in page or "the folder" in page
# --- Seeds ----------------------------------------------------------------------
def test_a_folder_seeds_a_new_chats_model(client: TestClient, db, registered):
_add_connection(db)
db.add(Model(connection_id=db.scalar(select(Connection)).id, model_id="other-model"))
db.commit()
folder = _folder(db, "Errands", model_id="other-model")
client.post("/api/chats/start", data={"content": "Hello", "folder_id": folder.id})
chat = db.scalar(select(Chat))
assert chat.model_id == "other-model"
def test_an_explicit_choice_beats_the_folders_seed(client: TestClient, db, registered):
"""The folder says what this work usually needs; the screen in front of
somebody says what they want this time."""
_add_connection(db)
db.add(Model(connection_id=db.scalar(select(Connection)).id, model_id="other-model"))
db.commit()
folder = _folder(db, "Errands", model_id="other-model")
client.post(
"/api/chats/start",
data={"content": "Hello", "folder_id": folder.id, "model_id": "test-model"},
)
assert db.scalar(select(Chat)).model_id == "test-model"
def test_a_folder_belonging_to_someone_else_seeds_nothing(client: TestClient, db, registered):
_add_connection(db)
other = User(name="Sam", email="sam@shire.test", password_hash="x")
db.add(other)
db.commit()
folder = Folder(user_id=other.id, name="Theirs", model_id="other-model")
db.add(folder)
db.commit()
client.post("/api/chats/start", data={"content": "Hello", "folder_id": folder.id})
chat = db.scalar(select(Chat))
assert chat.model_id == "test-model"
def test_a_deleted_connection_on_a_folder_does_not_raise(client: TestClient, db, registered):
"""`ssh_profile_id` is a plain string, not a foreign key, so it can outlive
the profile it names. It is validated on read instead."""
_add_connection(db)
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
folder = _folder(db, "Errands", kind="agent", ssh_profile_id="gone")
response = client.post(
"/api/chats/start", data={"content": "Hello", "folder_id": folder.id}
)
assert response.status_code == 204
# No profile means no agent chat: `_agent_target` refuses rather than
# creating one pointed at nothing.
assert db.scalar(select(Chat)).kind == "chat"
def test_the_seeds_are_saved_and_cleared_through_one_route(client: TestClient, db, registered):
"""Every field clearable, which is what reading the raw form buys: with
`Form(None)` an empty box and an absent one are the same request."""
folder = _folder(db, "Errands")
client.patch(
f"/api/folders/{folder.id}",
data={
"description": "Work on the tower.",
"system_prompt": "Answer in the fewest words.",
"model_id": "test-model",
"kind": "agent",
},
)
db.refresh(folder)
assert folder.description == "Work on the tower."
assert folder.system_prompt == "Answer in the fewest words."
assert folder.kind == "agent"
client.patch(f"/api/folders/{folder.id}", data={"system_prompt": "", "kind": ""})
db.refresh(folder)
assert folder.system_prompt == ""
assert folder.kind == ""
# Untouched keys are left alone rather than blanked.
assert folder.description == "Work on the tower."
def test_a_kind_that_is_not_a_kind_is_dropped(client: TestClient, db, registered):
"""A folder seeding a kind that is not a kind hands every chat a value
`_new_chat` then has to ignore anyway."""
folder = _folder(db, "Errands")
client.patch(f"/api/folders/{folder.id}", data={"kind": "wizard", "agent_mode": "reckless"})
db.refresh(folder)
assert folder.kind == ""
assert folder.agent_mode == ""
# --- Getting into a folder at all -----------------------------------------------
def test_new_chat_here_files_the_chat(client: TestClient, db, registered):
"""`/api/chats/start` has accepted a folder_id since folders existed and
nothing ever sent one."""
_add_connection(db)
folder = _folder(db, "Errands")
page = client.get(f"/chat?folder={folder.id}").text
assert f'name="folder_id" value="{folder.id}"' in page
client.post("/api/chats/start", data={"content": "Hello", "folder_id": folder.id})
assert db.scalar(select(Chat)).folder_id == folder.id
def test_a_folder_fixed_to_agent_opens_the_new_chat_screen_on_that_fork(
client: TestClient, db, registered
):
_add_connection(db)
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
db.add(
SshProfile(
owner_id=db.scalars(select(User).order_by(User.created_at)).first().id,
name="Box",
host="example.test",
username="root",
host_key="ssh-ed25519 AAAA",
)
)
db.commit()
folder = _folder(db, "Errands", kind="agent")
page = client.get(f"/chat?folder={folder.id}").text
assert 'name="kind" value="agent"' in page
# --- The settings page ----------------------------------------------------------
def test_the_settings_page_renders_what_is_stored(client: TestClient, db, registered):
_add_connection(db)
folder = _folder(db, "Errands", system_prompt="Answer in the fewest words.")
page = client.get(f"/folders/{folder.id}").text
assert "Answer in the fewest words." in page
assert f'hx-patch="/api/folders/{folder.id}"' in page
def test_the_settings_page_refuses_someone_elses_folder(client: TestClient, db, registered):
other = User(name="Sam", email="sam@shire.test", password_hash="x")
db.add(other)
db.commit()
folder = Folder(user_id=other.id, name="Theirs")
db.add(folder)
db.commit()
assert client.get(f"/folders/{folder.id}").status_code == 404
def test_the_settings_form_posts_at_a_route_that_serves_patch(
client: TestClient, db, registered
):
"""A control wired to a method its route does not serve fails silently --
htmx surfaces nothing, so it looks exactly like a control that works."""
folder = _folder(db, "Errands")
assert client.post(f"/api/folders/{folder.id}", data={"name": "x"}).status_code == 405
assert client.patch(f"/api/folders/{folder.id}", data={"name": "x"}).status_code == 204
+66
View File
@@ -0,0 +1,66 @@
"""Dialogs and toasts, checked without a runtime.
There is no JavaScript test runner here and hard rule 1 keeps Node out of the
project, so the behaviour is driven by hand under a DOM stub before committing.
What can be pinned in the suite are the invariants the file states about itself
-- and in particular the one that would look like an improvement to somebody
tidying up later.
"""
from __future__ import annotations
from pathlib import Path
import lembas
ROOT = Path(lembas.__file__).parent
SOURCE = (ROOT / "web/static/js/ui.js").read_text(encoding="utf-8")
TEMPLATES = ROOT / "web/templates"
def test_the_dialogs_never_fall_back_to_the_browsers_own():
"""`window.confirm` and `window.prompt` cannot be styled, ignore the theme
and block the tab. Putting one back is the thing this module exists to
prevent."""
assert "window.confirm(" not in SOURCE
assert "window.prompt(" not in SOURCE
def test_no_template_uses_htmx_s_own_prompt():
"""htmx's hx-prompt calls the browser's prompt() *synchronously* and only
then fires htmx:prompt with the answer already in hand -- so intercepting
the event cannot supply a different one, and the native box appears
regardless. `data-prompt` exists because of that, and an hx-prompt slipped
in later would summon the grey box back with nothing to catch it.
hx-confirm is fine and is used widely: that one fires *before*, and ui.js
intercepts it.
"""
# The attribute, not the word: the comment beside `data-prompt` names
# hx-prompt in order to say why it is not being used.
offenders = [
path.relative_to(TEMPLATES)
for path in TEMPLATES.rglob("*.html")
if 'hx-prompt="' in path.read_text(encoding="utf-8")
]
assert not offenders, f"hx-prompt summons window.prompt: {offenders}"
def test_the_prompt_answer_is_json_encoded_rather_than_concatenated():
"""A folder called `"` would otherwise produce hx-vals that does not parse,
and htmx would send the request with the field missing rather than with the
name -- a rename that silently does nothing."""
start = SOURCE.index("[data-prompt]")
block = SOURCE[start : SOURCE.index("data-confirm", start)]
assert "JSON.stringify" in block
def test_every_prompt_button_names_a_field_or_takes_the_default():
"""The field name is what the route reads. A button with a field the route
does not look at posts nothing and looks exactly like one that works."""
for path in TEMPLATES.rglob("*.html"):
text = path.read_text(encoding="utf-8")
if "data-prompt=" not in text:
continue
# Either an explicit field, or the "name" default the handler applies.
assert "data-prompt-field" in text or "/api/folders" in text, path