"""The sidebar's Chat/Agent switch: what it stores, and what it hides.""" from __future__ import annotations from fastapi.testclient import TestClient from sqlalchemy import select from lembas.db.models import Chat, Connection, Folder, Model, User from lembas.services import settings_store from lembas.services.crypto import encrypt def _enable_agents(db) -> None: """The split only applies when agent chats are possible. With them off the sidebar deliberately goes back to showing everything, so a test that did not do this would be asserting against the un-split behaviour and passing for the wrong reason. """ settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS) 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 _chat(db, *, title: str, kind: str = "chat", folder: Folder | None = None) -> Chat: user = db.scalars(select(User).order_by(User.created_at)).first() chat = Chat( user_id=user.id, title=title, kind=kind, model_id="test-model", folder_id=folder.id if folder else None, ) db.add(chat) db.commit() return chat def _folder(db, name: str, parent: Folder | None = None) -> 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) db.add(folder) db.commit() return folder # --- What the switch stores --------------------------------------------------- def test_the_switch_stores_the_choice_and_returns_the_tree(client: TestClient, db, registered): response = client.post("/api/preferences/sidebar-kind", data={"kind": "agent"}) assert response.status_code == 200 # The fragment, not a redirect and not a full page: the folder open/closed # state must survive a flick of the switch. assert 'id="sidebar-tree"' in response.text assert "" not in response.text.lower() user = db.scalars(select(User).order_by(User.created_at)).first() db.refresh(user) assert user.settings_json["sidebar_kind"] == "agent" def test_an_unknown_kind_is_refused_rather_than_stored(client: TestClient, db, registered): """`sidebar_kind` reads anything unrecognised back as "chat", so storing it would be a preference that silently does nothing.""" assert client.post("/api/preferences/sidebar-kind", data={"kind": "wizard"}).status_code == 400 user = db.scalars(select(User).order_by(User.created_at)).first() db.refresh(user) assert "sidebar_kind" not in (user.settings_json or {}) def test_the_new_chat_button_follows_the_switch(client: TestClient, db, registered): """It sits above the scroll area rather than inside the tree, so a swap of the tree alone left it saying "New chat" while agent chats were listed underneath -- saying one thing and doing another, which is the shape of failure the switch itself was arranged to avoid. Asserted on the *fragment*, not on a page load: a page load re-renders the button anyway, which is exactly why this went unnoticed. """ _add_connection(db) _enable_agents(db) response = client.post("/api/preferences/sidebar-kind", data={"kind": "agent"}) assert 'id="sidebar-actions"' in response.text assert 'hx-swap-oob="true"' in response.text assert "New agent chat" in response.text assert 'href="/chat?kind=agent"' in response.text response = client.post("/api/preferences/sidebar-kind", data={"kind": "chat"}) assert "New agent chat" not in response.text assert 'href="/chat"' in response.text def test_a_page_load_carries_no_stray_out_of_band_row( client: TestClient, db, registered ): """`hx-swap-oob` on a full page load would be a duplicate element sitting in the tree, waiting to be swapped by the next unrelated request.""" _add_connection(db) _enable_agents(db) page = client.get("/chat").text assert page.count('id="sidebar-actions"') == 1 assert 'id="sidebar-actions"\n hx-swap-oob' not in page assert "hx-swap-oob" not in page.split('id="sidebar-tree"')[1][:2000] def test_the_switch_survives_a_page_load(client: TestClient, db, registered): _add_connection(db) _enable_agents(db) client.post("/api/preferences/sidebar-kind", data={"kind": "agent"}) page = client.get("/chat").text assert 'id="sidebar-kind-agent"' in page # The Agent side is what "New chat" opens on, so the fork is already picked. assert 'href="/chat?kind=agent"' in page # --- What each side shows ------------------------------------------------------ def test_each_side_shows_only_its_own_kind(client: TestClient, db, registered): _add_connection(db) _enable_agents(db) _chat(db, title="An ordinary question") _chat(db, title="A machine errand", kind="agent") page = client.get("/chat").text assert "An ordinary question" in page assert "A machine errand" not in page client.post("/api/preferences/sidebar-kind", data={"kind": "agent"}) page = client.get("/chat").text assert "A machine errand" in page assert "An ordinary question" not in page def test_a_folder_of_the_other_kind_is_hidden(client: TestClient, db, registered): _add_connection(db) _enable_agents(db) folder = _folder(db, "Errands") _chat(db, title="A machine errand", kind="agent", folder=folder) # Chat side: the folder holds something, but nothing of this kind. assert "Errands" not in client.get("/chat").text client.post("/api/preferences/sidebar-kind", data={"kind": "agent"}) page = client.get("/chat").text assert "Errands" in page assert "A machine errand" in page def test_a_folder_matching_three_levels_down_is_shown(client: TestClient, db, registered): """Judging a folder on its own contents alone would bury it.""" _add_connection(db) _enable_agents(db) top = _folder(db, "Top") middle = _folder(db, "Middle", parent=top) bottom = _folder(db, "Bottom", parent=middle) _chat(db, title="A machine errand", kind="agent", folder=bottom) client.post("/api/preferences/sidebar-kind", data={"kind": "agent"}) page = client.get("/chat").text assert "Top" in page assert "Middle" in page assert "Bottom" in page assert "A machine errand" in page def test_an_empty_folder_shows_on_both_sides(client: TestClient, db, registered): """Two reasons a folder can look empty, and only one is a reason to hide it. A folder the filter emptied is noise. A folder that was empty to begin with is a container somebody just made -- hiding that one means it can never be found again, let alone filed into. """ _add_connection(db) _enable_agents(db) _folder(db, "Waiting") assert "Waiting" in client.get("/chat").text client.post("/api/preferences/sidebar-kind", data={"kind": "agent"}) assert "Waiting" in client.get("/chat").text def test_a_folder_holding_both_kinds_shows_the_right_half(client: TestClient, db, registered): _add_connection(db) _enable_agents(db) folder = _folder(db, "Mixed") _chat(db, title="An ordinary question", folder=folder) _chat(db, title="A machine errand", kind="agent", folder=folder) page = client.get("/chat").text assert "Mixed" in page assert "An ordinary question" in page assert "A machine errand" not in page client.post("/api/preferences/sidebar-kind", data={"kind": "agent"}) page = client.get("/chat").text assert "Mixed" in page assert "A machine errand" in page assert "An ordinary question" not in page # --- Whether the switch appears at all ----------------------------------------- def test_the_switch_is_absent_when_agent_chats_are_off(client: TestClient, db, registered): """A two-way switch with one useful side is worse than no switch: it offers a view that is empty by construction and cannot be made otherwise.""" _add_connection(db) settings_store.update(db, {"enabled": False}, key=settings_store.AGENTS) page = client.get("/chat").text assert 'id="sidebar-kind-agent"' not in page def test_the_switch_is_present_when_agent_chats_are_on(client: TestClient, db, registered): _add_connection(db) settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS) page = client.get("/chat").text assert 'id="sidebar-kind-agent"' in page assert 'id="sidebar-kind-chat"' in page # --- The row itself ------------------------------------------------------------ def test_an_agent_chat_is_marked_in_the_row(client: TestClient, db, registered): """Legible with the switch off too: a chat that can run commands should not look like one that cannot.""" _add_connection(db) _enable_agents(db) _chat(db, title="A machine errand", kind="agent") client.post("/api/preferences/sidebar-kind", data={"kind": "agent"}) page = client.get("/chat").text assert "#i-terminal" in page # --- The verb goes where the event does ---------------------------------------- def test_the_switch_posts_at_a_route_that_serves_post(client: TestClient, db, registered): """Asserted as a refusal as well as a success. A control wired to a method its route does not serve fails silently -- htmx surfaces nothing, so the interface looks exactly like one that works.""" assert client.get("/api/preferences/sidebar-kind").status_code == 405 assert client.post("/api/preferences/sidebar-kind", data={"kind": "chat"}).status_code == 200 # --- Pinned models ---------------------------------------------------------------- def _pin(db, model_id: str = "test-model") -> None: model = db.scalar(select(Model).where(Model.model_id == model_id)) model.pinned = True db.commit() def test_a_pinned_model_carries_the_side_the_switch_is_on( client: TestClient, db, registered ): """Picking a pinned model on the Agent side used to open an ordinary chat, which is the fork silently ignoring the one choice already made.""" _add_connection(db) _enable_agents(db) _pin(db) page = client.get("/chat").text assert 'href="/chat?model=test-model"' in page client.post("/api/preferences/sidebar-kind", data={"kind": "agent"}) page = client.get("/chat").text assert 'href="/chat?model=test-model&kind=agent"' in page def test_the_pinned_links_follow_the_switch_without_a_reload( client: TestClient, db, registered ): """They sit above the tree the switch swaps, so they arrive out of band -- the same shape as the New chat button. Asserted on the *fragment*: a page load re-renders them anyway, which is how the New chat button went unnoticed. """ _add_connection(db) _enable_agents(db) _pin(db) response = client.post("/api/preferences/sidebar-kind", data={"kind": "agent"}) assert 'id="sidebar-pinned"' in response.text assert "kind=agent" in response.text response = client.post("/api/preferences/sidebar-kind", data={"kind": "chat"}) assert 'href="/chat?model=test-model"' in response.text def test_the_pinned_group_is_always_there_to_be_swapped( client: TestClient, db, registered ): """Rendered even with nothing pinned. A block that vanished when the last model was unpinned would leave the out-of-band fragment with nowhere to land, and htmx says nothing at all when a target is missing.""" _add_connection(db) _enable_agents(db) assert 'id="sidebar-pinned"' in client.get("/chat").text response = client.post("/api/preferences/sidebar-kind", data={"kind": "agent"}) assert 'id="sidebar-pinned"' in response.text # And it says nothing when there is nothing to say. assert "Pinned models" not in response.text def test_an_empty_pinned_group_takes_no_room(): """It is in the DOM whether or not it holds anything, so it has to collapse or every sidebar with nothing pinned gains a gap.""" from pathlib import Path import lembas css = (Path(lembas.__file__).parent / "web/static/css/app.css").read_text(encoding="utf-8") assert ".nav-group--pinned:empty" in css def test_the_shortcuts_are_on_every_page_that_shows_the_sidebar( client: TestClient, db, registered ): """They came from the chat page's own context, so the library and the connections pages carried the sidebar without them -- a shortcut that is there on one page and gone on the next.""" _add_connection(db) _pin(db) for path in ("/chat", "/library/knowledge", "/agents", "/settings"): assert "Pinned models" in client.get(path).text, path