Two selects that never wrote anything, and a queue

The approval card in Auto mode and the missing /effort were one bug. Both
selects hung their hx-patch on an empty sibling form reached by form="…",
and htmx binds a trigger to the annotated element: change fires on the
select and bubbles to its ancestors, which a sibling is not. The live rows
read agent_mode=manual and params_json={} while the browser showed Auto and
Effort: high. policy.py was never involved.

The verb moves onto the control; the empty form stays as value scoping,
which is the half of the CLAUDE.md note that was right. conftest gains
control_named so a test asserts the element carrying the name carries the
verb, rather than asserting the markup that was there throughout.

The composer's highlight was a third instance of the same carelessness in
CSS: .tok-mention is written for the transcript and scoped to nothing, so
the mirror painted its token in accent-coloured monospace over the
textarea's own text. Scoped under .msg; the mirror restates transparency
and font rather than inheriting them, and bleeds by box-shadow.

/effort is now offered before the first prompt and _new_chat reads it.
/index re-walks the project directory on demand, file_write drops the
listing it just invalidated, and the index ladder falls through to SFTP on
a host that refuses exec instead of returning nothing.

A second message during a reply is queued rather than starting a second
concurrent generation: a real Message row with queued set, so it survives a
restart and can be withdrawn. _drain hands one on at the end of a reply,
_inject takes one in at a tool-round boundary so an agent can be steered
mid-task. Stop leaves the queue undelivered. The terminal's Auto toggle
becomes off/copy/send, and send posts straight to the chat without touching
the composer.

@ now offers notes, skills, this chat's attachments and a URL to fetch; a
knowledge base attaches as a reference rather than a copy. copy_document
carries provenance, which was the one attach path that dropped it.

Also fixes an unrelated live bug: the round loop compared against the
global MAX_ROUNDS of 3 while sizing itself from the agent budget of 40, so
agent replies stopped after three rounds and reported forty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 19:49:34 +02:00
parent 0bee366488
commit 8a3a225fea
31 changed files with 2131 additions and 81 deletions
+103 -1
View File
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
pytest # 1009 tests, ~58s
pytest # 1051 tests, ~60s
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
@@ -392,6 +392,22 @@ than appear as an empty heading. Anything else wanting the listing gets the same
deal: the `@` picker offers no files until one exists, because a keystroke must
never wait on a machine.
**And it only ever goes stale in one direction.** `_warm_index` returns early
whenever anything is cached, so within the 300s TTL a reply never re-walks;
after it lapses, the next reply rebuilds. What that misses is the tree changing
underneath — so `file_write` calls `index.forget_dir` for the directory it just
wrote into (the one place the cache is *known* wrong, and a model reading a
stale listing concludes the file it created does not exist), and `/index`
`POST /api/chats/{id}/index` is the "look again now" for everything else,
notably anything done by hand in the terminal panel. Read-only, so it is outside
`agent/policy.py` for the reason the directory browser is.
**The ladder falls through on failure, not just on absence.** `_from_git` and
`_from_find` raising `ExecError` — an SFTP-only account, a forced command, a
shell of `/bin/false` — used to escape the loop and be caught outside it,
returning an empty listing without ever trying the SFTP rung that exists for
exactly that host. Each rung catches its own now.
**A listing is budgeted, not dumped.** A tree of a thousand files costs the
window on every request forever and buries the four names that mattered.
`index.render` collapses what will not fit to `src/vendor/ (412 files)` and says
@@ -448,6 +464,34 @@ selects in the composer — the agent mode and the effort — belong to empty
`form="…"`. A form cannot nest inside another; the browser silently drops the
inner one, and the control then posts nothing at all.
**`form="…"` scopes the values; it does not route the event.** That is half of
the paragraph above, and taking it for the whole cost both those selects an
entire release in which they wrote nothing. htmx binds a trigger listener to the
annotated element itself unless `from:` says otherwise —
`if(c.from){t=m(l,c.from)}else{t=[l]}` — and `change` fires on the select and
bubbles through its **DOM ancestors**, which a sibling form is not. So the verb
goes **on the control**; the empty form stays, earning its keep as the answer to
htmx's "whose values are these", which is `function Nt(e){return e.form||g(e,"form")}`
`e.form` first, so a form-associated control resolves to the empty form and
the PATCH carries that one field. Without it, `closest("form")` finds the
composer and the request carries `project_dir`, which `update_chat` answers with
a 409. `tests/conftest.py:control_named` exists to pin this: the element
carrying the `name` must be the element carrying the verb. The three failures in
this feature — `hx-post` at a PATCH-only route, a menu built after it was
written to, a trigger bound where the event does not go — were all *silent*, and
all three had passing tests that asserted the markup rather than the property.
**A rule written for one context matches every context.** `.tok-mention` styles
a mention in the transcript: accent colour, monospace, 0.95em. Nothing scoped it
there, so it also hit the composer mirror's spans — and `.composer__mirror`'s
`color: transparent` is *inherited*, which loses to a colour the span declares
itself. The mirror painted its token visibly, in a different font, over the
textarea's own text: doubled, and shifted from that point on because the metrics
differ. Transcript token styles are `.msg .tok-*`; the mirror's restate
`color: transparent` and `font: inherit` rather than relying on inheritance, and
bleed with `box-shadow` rather than padding and a negative margin, because a
shadow cannot move a glyph.
**Unused columns are worse than missing ones.** `Model.params_json` documented
itself as "default sampling params applied to new chats using this model" and
was applied nowhere for its entire existence, which is how a per-model default
@@ -463,6 +507,17 @@ where it came from into `chat.document_context`'s tag, because a model handed
asked to change something. Those two are attribute values in a tag we write, so
`_attr` strips quotes and angle brackets rather than escaping them.
**`@` offers everything a chat can reach, and a knowledge base is the exception
that proves the rule.** Project files, documents, notes, skills, this chat's
earlier attachments and a URL all resolve to *an attachment*, copied — a
transcript must not change because somebody edited a note afterwards, the same
rule as PDF extraction. A **base** is a reference instead: `POST
/api/chats/{id}/bases` puts it on `Chat.knowledge_bases`, which already narrows
`knowledge_search`, and the harness already names the attached bases. Copying a
folder of contracts into the window would cost the context on every request
forever to answer one question. It therefore needs an existing chat, so it is
absent on the new-chat screen — the same reason project files are.
**Unread is polled, not pushed.** A browser on another chat has no connection
to the one that finished. `/api/chats/unread` returns out-of-band dot spans and
an `HX-Trigger` for the toast; `unread_notified` stops the same arrival being
@@ -526,6 +581,43 @@ the attribute to win. Anything toggled with `hidden` depends on that line.
`ui.js` flips `data-composer-action` plus `type` (`submit``button`) when a
message in the thread is still streaming. Do not add a second button back.
**A second message while a reply streams is queued, not sent.** It used to be
accepted outright: a second assistant placeholder, a second concurrent
`Generation` answering the same chat from a different prefix of it, and
`ui.js`'s first-match `querySelector(".msg[sse-connect]")` pointing Stop at
whichever bubble came first in the document. A queued turn is a real `Message`
with `queued` set — so it survives a restart, is in the transcript the moment it
is typed, and can be withdrawn before it is ever sent. `build_messages` skips
it. It must never carry `sse-connect`; the streaming shell is still the only
thing that starts a generation, so one on a queued bubble is that second
generation again.
**Delivery is two places, and neither is a special case.** `_drain` runs in
`_run`'s `finally:` between `_persist` and `done` — after the row is
authoritative, before the flag `_follow` breaks on, because the `done` frame is
the last thing that reaches a browser and has to carry the next turn's bubbles
out of band. It takes **one** waiting prompt, not all of them: draining the lot
puts two consecutive user turns in the next request. `_inject` takes one *into*
a reply at a tool-round boundary, which is the point of queueing in an agent
chat — steering work already under way — and restamps the assistant
placeholder's `created_at` so the reply still sorts before the prompt it
answered. It refuses on the last round: a prompt delivered into a reply that
then runs out of budget is marked delivered and never sent again.
**Stop leaves the queue alone**, deliberately, and `_drain` refuses on stopped,
errored and superseded. Stopped is the reader's decision; errored would feed the
next prompt into an endpoint that has just failed; superseded is the same test
`_persist` makes, without which regenerating drains the queue as a side effect.
Cancellation sets `stopped`, so a restart never fires off a reply with nobody
watching.
**An interjection is sent verbatim, in the user role.** Everything else this
codebase injects is quoted and attributed because it came out of a file, a page
or a machine; this one genuinely is the person at the keyboard. Wrapping it
would teach a model that a user turn can be a quotation, which is the exact
distinction `execute_plan` and `Capture.as_text` rely on. What the model needs —
that this can happen at all — is the `core.interjection` harness fragment.
**The tool loop is inside one generation.** `services/generation.py:_run()` runs
up to `tools_service.MAX_ROUNDS` request rounds for a single reply: stream,
accumulate tool calls, run them, append the results, ask again. `Generation`
@@ -838,6 +930,16 @@ therefore no markers — at which point Copy and Send fall back to scraping the
screen and say so, and the automatic toggle is **disabled rather than degraded**.
Forty arbitrary lines attached to every message is worse than nothing attached.
**The automatic toggle has three states, and a select to say which.** Off, copy,
send. It was a boolean doing the wrong one of them: it appended into the
composer, on top of whatever was being typed there. `send` posts straight to
`/api/chats/{id}/messages` and never touches the composer — which is what makes
the queue load-bearing, since commands finish while a reply is running. Not
persisted between page loads, deliberately: a switch that forwards everything
you type in a shell to a model is not something to inherit from last week's
session. A cycling icon button was the obvious shape and cannot say which of
three states it is in.
**The nginx vhost must pass upgrades through.** `deploy/nginx-vhost.conf` used
to set `Connection ""`, which is right for SSE and fails every WebSocket
handshake — and a failed handshake tells the browser nothing: no status, no
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "lembas"
version = "0.6.1"
version = "0.6.2"
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "0.6.1"
__version__ = "0.6.2"
+332 -5
View File
@@ -10,8 +10,8 @@ from collections.abc import AsyncIterator
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, Response, StreamingResponse
from sqlalchemy import select
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser, require_permission
@@ -49,6 +49,12 @@ router = APIRouter(prefix="/api/chats", tags=["chats"])
# Well under nginx's 60s default; see services/sse.py:KEEPALIVE.
KEEPALIVE_AFTER = 15.0
# How many prompts may wait behind a reply at once. The terminal panel's Auto
# send is what this exists for: a `for` loop in a shell can produce commands
# faster than any model answers them, and a bound with a sentence attached is
# better than four hundred rows nobody meant to write.
MAX_QUEUED = 10
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
chat = db.get(Chat, chat_id)
@@ -70,6 +76,7 @@ def _new_chat(
ssh_profile_id: str = "",
project_dir: str = "",
agent_mode: str = "",
reasoning_effort: str = "",
) -> Chat:
"""Create a chat row, resolving which model it should use.
@@ -81,7 +88,8 @@ def _new_chat(
before the first word. Without it, reaching Plan mode meant starting a chat
in Manual, sending something to make the chat exist, and only then being
offered the control -- by which point the model had already answered under
the wrong rules.
the wrong rules. The reasoning effort is accepted for the same reason, and
wins over the model's default: an explicit choice beats an inherited one.
"""
chosen = None
if model_id:
@@ -126,6 +134,12 @@ def _new_chat(
# Left alone entirely on a plain chat, where it means nothing.
if profile is not None and agent_mode.strip() in agent_policy.MODES:
chat.agent_mode = agent_mode.strip()
# After the model's defaults, so choosing one on the new-chat screen wins
# over the administrator's. Empty means "whatever the model said", not
# "none" -- clearing it is what the blank option on an existing chat does.
wanted_effort = reasoning_effort.strip().lower()
if wanted_effort in chat_service.EFFORTS:
chat.params_json = {**chat.params_json, "reasoning_effort": wanted_effort}
db.add(chat)
db.commit()
return chat
@@ -144,6 +158,7 @@ async def start_chat(
ssh_profile_id: str = Form(""),
project_dir: str = Form(""),
agent_mode: str = Form(""),
reasoning_effort: str = Form(""),
) -> Response:
"""Create a chat from its first message.
@@ -166,6 +181,7 @@ async def start_chat(
ssh_profile_id=ssh_profile_id,
project_dir=project_dir,
agent_mode=agent_mode,
reasoning_effort=reasoning_effort,
)
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
@@ -400,6 +416,108 @@ async def compact_chat(request: Request, db: Db, user: RequiredUser, chat_id: st
)
@router.post("/{chat_id}/index")
async def reindex_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
"""Walk the project directory again, now.
The listing is cached for five minutes and only ever built when a reply
starts, so a tree that has just changed under somebody's hands -- a checkout,
a build, anything done in the terminal panel rather than through
`file_write` -- stays wrong until the next reply after the TTL lapses. This
is the "look again" that was missing.
Read-only, and therefore outside `agent/policy.py` for the reason the
directory browser is: it is LLeMbas acting on a person's instruction, not a
model choosing to look, and a listing that asked permission would be
useless. The gate is ownership of the connection, checked here rather than
trusted from the chat.
"""
from lembas.db.models import SshProfile
from lembas.services.agent import index as index_service
from lembas.services.agent import ssh as ssh_service
chat = _owned_chat(db, chat_id, user.id)
if chat.kind != KIND_AGENT or not chat.ssh_profile_id:
raise HTTPException(status.HTTP_409_CONFLICT, "This chat has no project directory.")
if not permissions.has(db, user, "tools.agent"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not use agent connections.")
profile = db.get(SshProfile, chat.ssh_profile_id)
if profile is None or profile.owner_id != user.id or not profile.enabled:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection is not available.")
if not profile.host_key:
raise HTTPException(
status.HTTP_409_CONFLICT,
"This connection's host key has not been accepted yet.",
)
project_dir = chat.project_dir or profile.default_dir or ""
try:
found = await index_service.ensure(
ssh_service.SshExecutor(ssh_service.spec_from(profile), project_dir),
profile.id,
project_dir,
refresh=True,
)
except Exception as exc: # noqa: BLE001 - surfaced to the reader, not swallowed
log.warning("could not index %s for chat %s: %s", project_dir, chat.id, exc)
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, "Could not read the project directory."
) from exc
listed = len(found.paths)
return JSONResponse(
{
"ok": True,
"files": found.total,
"listed": listed,
"truncated": found.truncated,
"message": (
f"{found.total} files under {project_dir or '~'}"
+ (f", {listed} listed." if listed != found.total else ".")
),
}
)
@router.post("/{chat_id}/bases")
async def attach_base(
request: Request, db: Db, user: RequiredUser, chat_id: str, base_id: str = Form("")
) -> Response:
"""Scope this chat to a knowledge base, from the `@` menu.
A base is a *reference*, not an attachment: `Chat.knowledge_bases` already
narrows `knowledge_search`, and the harness already names the attached bases
so the model can tell "there is nothing about this" from "I can only see
this folder". Copying a folder of documents into the window instead would
cost the context on every request forever to answer one question.
Additive, and idempotent -- choosing the same base twice is not an error.
Removing one is a checkbox in the chat's settings, where the whole set is
visible at once.
"""
from lembas.db.models import KnowledgeBase
from lembas.services.library import documents as documents_service
chat = _owned_chat(db, chat_id, user.id)
if not permissions.has(db, user, "library.use"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not use the library.")
base = db.scalar(
documents_service.visible_bases(db, user).where(KnowledgeBase.id == base_id)
)
if base is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
if base.id not in {b.id for b in chat.knowledge_bases}:
chat.knowledge_bases = [*chat.knowledge_bases, base]
db.commit()
return templates.TemplateResponse(
request, "chat/_base_chip.html", {"request": request, "base": base}
)
@router.post("/{chat_id}/keep")
async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
"""Stop a temporary chat being temporary.
@@ -489,6 +607,22 @@ async def post_message(
return _send(request, db, chat, user, content, file_ids=file_ids)
def _reply_in_flight(db: DBSession, chat: Chat) -> bool:
"""Whether this chat already has a reply being written.
The row is the authority, not the registry: a restart leaves an incomplete
assistant message behind with no `Generation` anywhere, and that row is what
starts the reply again on the next page load. The registry is consulted too,
for the sliver in which a generation is still running and its row has
already been written -- `_persist` sets `complete` before `_run` sets
`done`.
"""
unfinished = db.scalar(
select(Message).where(Message.chat_id == chat.id, Message.complete.is_(False))
)
return unfinished is not None or generation_service.running_for(chat.id) is not None
def _note_rewind(chat: Chat) -> None:
"""Record that an agent chat's transcript went back and the machine did not.
@@ -516,11 +650,53 @@ def _send(
Shared by the composer and by anything else that puts words into a
conversation on somebody's behalf -- carrying out a plan, for one. One path
rather than two, so a second way of sending cannot drift from the first.
If a reply is already being written, the turn is *queued* instead: written,
shown, and not sent. Starting a second reply here is what used to happen,
and it produced two generations answering the same chat from two different
prefixes of it, with Stop pointing at whichever bubble came first in the
document.
"""
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
if queued := _reply_in_flight(db, chat):
waiting = db.scalar(
select(func.count())
.select_from(Message)
.where(Message.chat_id == chat.id, Message.queued.is_(True))
)
if waiting >= MAX_QUEUED:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"There are already {MAX_QUEUED} messages waiting to be sent.",
)
user_message = chat_service.create_message(db, chat, ROLE_USER, content, queued=queued)
if file_ids:
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)
db.refresh(user_message)
if queued:
# One bubble and no assistant placeholder. The streaming shell is the
# only thing that starts a generation, so a placeholder here would be a
# second concurrent reply -- exactly what the queue exists to prevent.
if (live := generation_service.running_for(chat.id)) is not None:
# So a reply between two rounds of tool calls notices it, and so
# anybody following sees the status change.
live.touch()
return templates.TemplateResponse(
request,
"chat/_message.html",
{
"request": request,
"message": user_message,
"chat": chat,
"user": user,
"models_by_id": {
m.model_id: m for m in chat_service.available_models(db, user)
},
**audio_service.template_flags(db, user),
},
)
assistant_message = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
)
@@ -680,10 +856,90 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
)
title_html = templates.get_template("chat/_title_oob.html").render({"chat": chat})
yield sse.event("done", final_html + title_html)
# What the queue did while this reply was running. There is no push
# channel that outlives one message's stream, and this is the last frame
# that reaches the browser -- so it carries the rest out of band, the
# way the chat title already does.
moved_html, queue_html = _queue_frames(db, chat, owner, generation)
yield sse.event("done", moved_html + final_html + queue_html + title_html)
yield sse.event("close", "")
def _render_bubble(db: DBSession, chat: Chat, owner: User | None, message: Message) -> str:
"""One finished bubble, rendered the way the `done` frame renders its own."""
return templates.get_template("chat/_message.html").render(
{
"message": message,
"body_html": (
render_markdown(message.content) if message.role == ROLE_ASSISTANT else ""
),
"chat": chat,
"user": owner,
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, None)},
**audio_service.template_flags(db, owner),
}
)
def _queue_frames(
db: DBSession, chat: Chat, owner: User | None, generation
) -> tuple[str, str]:
"""The bubbles the queue produced during this reply, as out-of-band HTML.
Two pieces, because they swap differently. Anything taken *into* this reply
mid-round now sorts before it, so it is rendered ahead of the finished
bubble in the same `outerHTML` swap and its stale node is deleted out of
band -- one frame, and the DOM ends up in the order the database is in.
Anything drained *after* the reply is a new pair appended to the thread.
"""
moved: list[str] = []
out_of_band: list[str] = []
for injected_id in generation.injected_ids:
row = db.get(Message, injected_id)
if row is None:
continue
moved.append(_render_bubble(db, chat, owner, row))
# Removed where it was; it is about to reappear above the reply.
out_of_band.append(f'<article id="msg-{row.id}" hx-swap-oob="delete"></article>')
if generation.drained:
fresh = list(
db.scalars(
select(Message)
.where(Message.chat_id == chat.id, Message.complete.is_(False))
.order_by(Message.created_at)
)
)
for assistant in fresh:
# The user turn that was waiting has just lost its Send now and
# Discard, so it is re-rendered in place.
delivered = db.scalars(
select(Message)
.where(
Message.chat_id == chat.id,
Message.role == ROLE_USER,
Message.created_at <= assistant.created_at,
)
.order_by(Message.created_at.desc())
.limit(1)
).first()
if delivered is not None:
out_of_band.append(
f'<div hx-swap-oob="outerHTML:#msg-{delivered.id}">'
+ _render_bubble(db, chat, owner, delivered)
+ "</div>"
)
out_of_band.append(
'<div hx-swap-oob="beforeend:#thread">'
+ _render_bubble(db, chat, owner, assistant)
+ "</div>"
)
return "".join(moved), "".join(out_of_band)
def _metrics_html(generation) -> str:
"""The metric chips for a reply still being written.
@@ -799,6 +1055,16 @@ async def edit_message(
if not content and not message.attachments:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "A message cannot be empty.")
# Editing rewinds and then starts a reply, unconditionally. Doing that while
# one is already being written is a second concurrent generation -- the
# thing the queue exists to prevent -- reachable here by a button that is on
# screen throughout. It was reachable before the queue too; nothing made it
# obvious.
if _reply_in_flight(db, chat):
raise HTTPException(
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
)
message.content = content
# Attachments cascade with their message, so the files go too.
@@ -884,6 +1150,67 @@ async def stop_message(db: Db, user: RequiredUser, chat_id: str, message_id: str
return Response(status_code=status.HTTP_204_NO_CONTENT)
def _waiting_message(db: DBSession, chat: Chat, message_id: str) -> Message:
message = db.get(Message, message_id)
if message is None or message.chat_id != chat.id or not message.queued:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "That message is not waiting to be sent."
)
return message
@router.post("/{chat_id}/messages/{message_id}/discard")
async def discard_queued(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> Response:
"""Withdraw a prompt that has not been sent.
Deleted outright rather than marked: it never reached a model, nothing in
the transcript refers to it, and a conversation full of tombstones for
things nobody said is worse than the row being gone. Attachments cascade.
An empty body rather than a 204, because htmx does not swap on a 204 and the
bubble has to disappear.
"""
chat = _owned_chat(db, chat_id, user.id)
message = _waiting_message(db, chat, message_id)
db.delete(message)
db.commit()
return HTMLResponse("")
@router.post("/{chat_id}/messages/{message_id}/send-now")
async def send_queued_now(
request: Request, db: Db, user: RequiredUser, chat_id: str, message_id: str
) -> Response:
"""Deliver a waiting prompt at once.
Refused while a reply is being written rather than allowed to jump ahead of
it: that is what the queue *is*, and starting a second generation here is
the thing this whole mechanism exists to stop. Stop the reply first.
The whole thread comes back, which is the rewind and compaction idiom, and
is safe only because of the refusal above -- there is no live bubble to
destroy.
"""
chat = _owned_chat(db, chat_id, user.id)
message = _waiting_message(db, chat, message_id)
if _reply_in_flight(db, chat):
raise HTTPException(
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
)
message.queued = False
db.commit()
assistant = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
)
generation_service.ensure(chat.id, assistant.id)
return templates.TemplateResponse(
request, "chat/_thread.html", {"request": request, **_thread_context(db, chat, user)}
)
@router.post("/{chat_id}/interaction/{interaction_id}")
async def answer_interaction(
request: Request,
+152 -1
View File
@@ -16,14 +16,17 @@ from fastapi import (
status,
)
from fastapi.responses import FileResponse
from sqlalchemy import select
from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.db.models import Attachment, Document
from lembas.db.models import Attachment, Document, KnowledgeBase, Note
from lembas.security import permissions
from lembas.services import files as files_service
from lembas.services import settings_store
from lembas.services.fetch import FetchError, fetch
from lembas.services.library import documents as documents_service
from lembas.services.library import notes as notes_service
from lembas.services.library import skills as skills_service
from lembas.web.templating import templates
log = logging.getLogger(__name__)
@@ -144,6 +147,102 @@ async def attach_from_knowledge(
)
def _chip(request: Request, attachment: Attachment) -> Response:
return templates.TemplateResponse(
request, "chat/_attachment_chip.html", {"request": request, "attachment": attachment}
)
def _not_available(request: Request, what: str) -> Response:
return templates.TemplateResponse(
request,
"chat/_attachment_error.html",
{"request": request, "filename": what, "error": f"That {what} is not available."},
)
@router.post("/from-note", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_note(
request: Request, db: Db, user: RequiredUser, note_id: str = Form(""), chat_id: str = Form("")
) -> Response:
"""Attach a note the model wrote earlier.
A copy, like every other attach path: a note is edited far more often than a
document, and a transcript that changes underneath itself because somebody
tidied a note later is the thing all of this is arranged to prevent.
"""
note = notes_service.get(db, note_id, user)
if note is None:
return _not_available(request, "note")
return _chip(
request,
files_service.store_text(
db,
user_id=user.id,
chat_id=chat_id or None,
filename=f"{note.title or 'note'}.txt",
text=note.body,
source_path=note.title or "",
source_label="Note",
),
)
@router.post("/from-skill", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_skill(
request: Request, db: Db, user: RequiredUser, skill_id: str = Form(""), chat_id: str = Form("")
) -> Response:
"""Hand a skill over directly, rather than hoping the model fetches it.
The index of enabled skills is already in the harness and `skill_get` pulls
a body on demand -- but only if the model decides to. `@` is the reader
saying "use this one", which is a different act and deserves a way to say it.
"""
skill = skills_service.get(db, skill_id, user)
if skill is None:
return _not_available(request, "skill")
return _chip(
request,
files_service.store_text(
db,
user_id=user.id,
chat_id=chat_id or None,
filename=f"{skill.name}.md",
text=skill.body,
source_path=skill.name,
source_label="Skill",
),
)
@router.post("/from-attachment", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_attachment(
request: Request,
db: Db,
user: RequiredUser,
attachment_id: str = Form(""),
chat_id: str = Form(""),
) -> Response:
"""Point at something already in this conversation, without uploading again.
Copied rather than referenced, like everything else here -- an attachment
belongs to the message it was sent with, and two messages sharing one row
would make deleting either of them a question rather than an answer.
"""
original = db.get(Attachment, attachment_id)
if original is None or original.user_id != user.id:
return _not_available(request, "attachment")
return _chip(
request,
files_service.copy_attachment(
db, user_id=user.id, chat_id=chat_id or None, attachment=original
),
)
@router.get("/knowledge-picker", dependencies=[Depends(require_permission("files.upload"))])
async def knowledge_picker(
request: Request, db: Db, user: RequiredUser, q: str = "", chat_id: str = ""
@@ -210,9 +309,14 @@ async def mention_picker(
][:20]
documents: list = []
notes: list = []
skills: list = []
bases: list = []
if permissions.has(db, user, "library.use"):
if needle:
documents = documents_service.search(db, user, q, limit=10)
notes = notes_service.search(db, user, q, limit=5)
skills = skills_service.search(db, user, q, limit=5)
else:
documents = list(
db.scalars(
@@ -221,6 +325,48 @@ async def mention_picker(
.limit(10)
)
)
notes = list(
db.scalars(
notes_service.visible(db, user).order_by(Note.updated_at.desc()).limit(5)
)
)
skills = list(db.scalars(skills_service.visible(db, user).limit(5)))
# A whole base is a *reference*, not a copy: attaching one scopes the
# chat to it and the model searches inside it. Dumping the contents of
# a folder of contracts into the window would be the wrong shape
# entirely, and `Chat.knowledge_bases` already means exactly this.
# Only in an existing chat, because there is nothing to attach it to
# before one exists -- the same reason project files are absent there.
if chat_id:
bases = [
base
for base in db.scalars(
documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)
)
if not needle or needle in base.name.lower()
][:5]
# A URL typed after `@` is a page to read, not a name to look up. The
# fetcher, its SSRF guard and its HTML-to-text already live behind
# `/api/files/link`; this only offers it.
website = q.strip() if q.strip().lower().startswith(("http://", "https://")) else ""
attachments: list = []
if chat_id and needle:
attachments = list(
db.scalars(
select(Attachment)
.where(
Attachment.user_id == user.id,
Attachment.chat_id == chat_id,
Attachment.message_id.is_not(None),
)
.order_by(Attachment.created_at.desc())
.limit(20)
)
)
attachments = [a for a in attachments if needle in a.filename.lower()][:5]
return templates.TemplateResponse(
request,
@@ -230,6 +376,11 @@ async def mention_picker(
"user": user,
"files": files,
"documents": documents,
"notes": notes,
"skills": skills,
"bases": bases,
"attachments": attachments,
"website": website,
"q": q,
"chat_id": chat_id,
"profile_id": profile_id,
+7
View File
@@ -223,6 +223,13 @@ class Message(UUIDPrimaryKey, Timestamps, Base):
# True when the reader pressed Stop. Distinct from `error`: the text that
# did arrive is kept and is perfectly usable, it is just cut short.
stopped: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Typed while a reply was still being written, and not yet handed to a
# model. A row rather than something held in the browser: it survives a
# restart, it is in the transcript the moment it is typed, and it can be
# withdrawn before it is ever sent. `build_messages` skips it; delivery --
# `generation._drain` at the end of a reply, or `_inject` between two rounds
# of tool calls -- is the only thing that clears it.
queued: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
chat: Mapped[Chat] = relationship(back_populates="messages")
attachments: Mapped[list[Attachment]] = relationship( # noqa: F821
+23 -2
View File
@@ -223,12 +223,21 @@ async def build(executor: Executor, project_dir: str) -> ProjectIndex:
"""Walk the directory, by whichever means works first."""
started = time.monotonic()
try:
found = None
for attempt in (_from_git, _from_find):
try:
found = await attempt(executor, project_dir)
except ExecError as exc:
# A rung that cannot run at all is a rung that did not answer,
# not the end of the ladder. A host that refuses exec entirely
# -- an SFTP-only account, a forced command -- is the exact case
# the SFTP rung below exists for, and letting this out skipped
# straight past it to an empty listing.
log.debug("indexing %s: %s did not run: %s", project_dir, attempt.__name__,
exc.message)
found = None
if found is not None:
break
else:
found = None
if found is None:
found = await _from_sftp(executor, project_dir)
except ExecError as exc:
@@ -491,6 +500,17 @@ def forget(profile_id: str) -> int:
return len(doomed)
def forget_dir(profile_id: str, project_dir: str) -> None:
"""Drop one tree's listing, because something just changed it.
The TTL exists for drift nobody can see coming. A write through `file_write`
is not that: it is this process changing the tree it has just described, and
leaving five minutes of a listing that is known to be wrong is worse than
having none -- a model reading it concludes the file it created is missing.
"""
_CACHE.pop((profile_id, project_dir), None)
def clear() -> None:
_CACHE.clear()
@@ -503,5 +523,6 @@ __all__ = [
"clear",
"ensure",
"forget",
"forget_dir",
"render",
]
+5
View File
@@ -36,6 +36,10 @@ class AgentContext:
chat_id: str
label: str
project_dir: str
# The connection's id, carried so a runner can drop the project listing it
# has just invalidated. `index` is keyed on the connection and the
# directory, not on the chat -- two chats on one tree share a listing.
profile_id: str = ""
mode: str = policy.MODE_MANUAL
allow: tuple[str, ...] = ()
deny: tuple[str, ...] = ()
@@ -112,6 +116,7 @@ def resolve(db: DBSession, chat: Chat, user: User | None) -> AgentContext | None
chat_id=chat.id,
label=profile.label,
project_dir=chat.project_dir or profile.default_dir or "",
profile_id=profile.id,
mode=chat.agent_mode if chat.agent_mode in policy.MODES else policy.MODE_MANUAL,
allow=tuple(values.get("allow_default") or ()),
deny=tuple(values.get("deny_default") or ()),
+8 -1
View File
@@ -21,7 +21,7 @@ import json
import logging
from typing import Any
from lembas.services.agent import policy
from lembas.services.agent import index, policy
from lembas.services.agent.base import ExecError, ExecRequest
from lembas.services.agent.session import AgentContext
from lembas.services.tools import (
@@ -198,6 +198,13 @@ async def _run_write(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
exc.message, _event("file_write", agent, path, status="error", error=exc.message)
)
# The tree just changed, and this process is what changed it. The listing's
# TTL is for drift nobody can see coming; leaving five more minutes of a
# listing known to be wrong makes a model conclude the file it has just
# written does not exist.
if agent.profile_id:
index.forget_dir(agent.profile_id, agent.project_dir)
return ToolOutcome(
f"Wrote {written} bytes to {path}.",
_event("file_write", agent, path, status="ok", text=f"{written} bytes"),
+7
View File
@@ -226,6 +226,11 @@ def build_messages(
message
) <= compaction_service.moment(cutoff):
continue
# Typed while the previous reply was still being written, and not yet
# handed to a model. It is in the transcript and it is not in the
# request; delivery is what moves it from one to the other.
if message.queued:
continue
# Skip turns that failed or produced nothing -- but a message carrying
# only an attachment has no text and must still be sent.
if message.error:
@@ -447,6 +452,7 @@ def create_message(
*,
complete_: bool = True,
model_id: str = "",
queued: bool = False,
) -> Message:
message = Message(
chat_id=chat.id,
@@ -454,6 +460,7 @@ def create_message(
content=content,
complete=complete_,
model_id=model_id,
queued=queued,
)
db.add(message)
db.commit()
+9 -2
View File
@@ -98,8 +98,12 @@ def split(
return [], list(messages)
boundary = moment(cutoff)
return (
[m for m in messages if moment(m) <= boundary],
[m for m in messages if moment(m) > boundary],
# A prompt still waiting to be sent stays on the live side whatever its
# timestamp says. Folding one into the "earlier messages" details would
# hide the only place its Send now and Discard exist, and it has not
# been part of any request to summarise.
[m for m in messages if not m.queued and moment(m) <= boundary],
[m for m in messages if m.queued or moment(m) > boundary],
)
@@ -133,6 +137,9 @@ def transcript(db: DBSession, chat: Chat, *, upto: Message) -> str:
Message.chat_id == chat.id,
Message.created_at <= upto.created_at,
Message.error == "",
# Not yet sent to anything. Summarising it would fold words the model
# has never seen into the record, and then deliver them again later.
Message.queued.is_(False),
)
if previous is not None:
query = query.where(Message.created_at > previous.created_at)
+43
View File
@@ -375,6 +375,43 @@ def store_text(
return attachment
def copy_attachment(
db: DBSession, *, user_id: str, chat_id: str | None, attachment: Attachment
) -> Attachment:
"""Duplicate something already sent, so it can ride along with a new message.
A copy and not a second reference to one row: an attachment belongs to the
message it was sent with, and sharing one between two would make deleting
either of them a question rather than an answer.
"""
stored_name = ""
source = attachments_dir() / attachment.stored_name if attachment.stored_name else None
if source is not None and source.exists():
stored_name = f"{secrets.token_hex(16)}{Path(source.name).suffix}"
(attachments_dir() / stored_name).write_bytes(source.read_bytes())
copy = Attachment(
user_id=user_id,
chat_id=chat_id,
filename=attachment.filename,
stored_name=stored_name,
media_type=attachment.media_type,
size_bytes=attachment.size_bytes,
kind=attachment.kind,
width=attachment.width,
height=attachment.height,
extracted_text=attachment.extracted_text,
pages=attachment.pages,
truncated=attachment.truncated,
extraction_error=attachment.extraction_error,
source_path=attachment.source_path,
source_label=attachment.source_label,
)
db.add(copy)
db.commit()
return copy
def copy_document(
db: DBSession, *, user_id: str, chat_id: str | None, document
) -> Attachment:
@@ -407,6 +444,12 @@ def copy_document(
pages=document.pages,
truncated=document.truncated,
extraction_error=document.extraction_error,
# Where it came from, for the same reason a project file carries it: a
# model handed four documents cannot tell which is which, and cannot
# name one back when asked to work on it. This was the one attach path
# that dropped provenance.
source_path=(document.title or "")[:1000],
source_label=(document.base.name if document.base else "Knowledge")[:200],
)
db.add(attachment)
db.commit()
+169 -1
View File
@@ -129,6 +129,13 @@ class Generation:
# the reply and is written onto the message, so the Execute button sends
# exactly what was proposed rather than something parsed back out of prose.
plan: dict | None = None
# The queue, seen from the reply's side. `drained` says this reply's ending
# handed the next waiting prompt to a fresh one; `injected_ids` names the
# prompts taken into *this* reply between two rounds of tool calls. Both are
# read only by `_follow`, which turns them into bubbles on the `done` frame
# -- the one frame that reaches a browser after a reply is over.
drained: bool = False
injected_ids: list[str] = field(default_factory=list)
def touch(self) -> None:
self.version += 1
@@ -187,6 +194,22 @@ def answer(
return False
def running_for(chat_id: str) -> Generation | None:
"""The reply being written in this chat, if there is one.
A linear scan for the reason `answer` gives above: one entry per reply in
flight, consulted at human speed. `_prune` first, because a finished
generation lingers `KEEP_FINISHED` so that late followers still get the
final frames -- and without the sweep those five minutes would look like a
chat that is permanently busy, and queue everything typed into it.
"""
_prune()
for generation in _RUNNING.values():
if generation.chat_id == chat_id and not generation.done:
return generation
return None
_VERDICTS = (interaction.ALLOW, interaction.ALLOW_ALWAYS, interaction.DENY)
@@ -328,6 +351,12 @@ async def _run(generation: Generation) -> None:
model = chat_service.model_for(db, chat)
generation.context_limit = model.context_length if model is not None else 0
# Kept for `_inject`, which builds a user turn after this session
# has closed. A turn taken in mid-reply has to be shaped exactly as
# the same words typed a moment later would have been -- images to a
# vision model, a plain string to anything else, or the endpoint
# rejects the whole request.
vision = chat_service.model_supports(db, chat, "vision")
generation.prompt_estimate = tokens.estimate_request(payload)
@@ -406,10 +435,17 @@ async def _run(generation: Generation) -> None:
if generation.stopped or not calls:
break
if round_number == tools_service.MAX_ROUNDS:
if round_number == budget:
# Out of rounds with the model still asking for tools. Recorded
# rather than silently dropped: an answer that stops here needs
# to be explicable.
#
# `budget`, not `MAX_ROUNDS`. The loop is sized by the budget on
# the line above and the message below has always reported it,
# but the comparison was against the global 3 -- so an agent
# chat allowed forty steps stopped after three and said it had
# taken forty. Two numbers, one of them wrong, in code whose
# whole job is to say what happened.
generation.tool_events.append(
{
"name": calls[0]["name"],
@@ -454,6 +490,22 @@ async def _run(generation: Generation) -> None:
generation.plan = outcome.event["plan"]
generation.touch()
# Something typed while this reply was working. Taken in here, at a
# round boundary, rather than made to wait for the whole reply: an
# agent that has just finished one loop and is about to start
# another is exactly when "actually, do it the other way" is worth
# having.
#
# Only while there is a round left to answer in. Injecting into the
# last one would deliver the prompt into a reply that then runs out
# of budget without addressing it -- and it is marked delivered, so
# nothing would ever send it again. Below that line it waits for
# `_drain`, which always gives it a reply of its own.
if round_number + 1 < budget and (
added := _inject(generation, generation.chat_id, vision)
):
messages.append(added)
payload = {**payload, "messages": messages}
# A plan ends the turn. One more request so the model can say what
@@ -525,6 +577,11 @@ async def _run(generation: Generation) -> None:
# the row. The other order left a window in which the finished frame
# showed the previous turn's stored values.
_persist(generation, title, time.monotonic() - started)
# After the row is authoritative and before `done`, for the same reason
# `_persist` is: `_follow` breaks the instant it sees that flag, and the
# frame it then sends is the one that has to carry the next turn's
# bubbles. There is no push channel that outlives a single reply.
_drain(generation)
generation.done = True
generation.finished_at = datetime.now(UTC)
generation.touch()
@@ -1006,6 +1063,117 @@ def _question_from(payload: dict) -> str:
return ""
def _next_waiting(db, chat_id: str) -> Message | None:
"""The oldest prompt in this chat that has not been sent."""
return db.scalars(
select(Message)
.where(
Message.chat_id == chat_id,
Message.role == ROLE_USER,
Message.queued.is_(True),
)
.order_by(Message.created_at)
.limit(1)
).first()
def _drain(generation: Generation) -> None:
"""Hand the next waiting prompt to a reply of its own, if there is one.
Exactly one, not all of them. Draining the lot would put two consecutive
user turns into the next request, which several local chat templates refuse
outright -- `build_messages` already goes to some trouble over that around
the compaction lead. "One after another" is also what was asked for: the
second waiting prompt is drained by the reply the first one starts, and so
on down the chain.
Three refusals, and none of them is a special case:
- **Superseded.** The same test `_persist` makes, for the same reason: a
regeneration cancels its predecessor and the predecessor's `finally:`
still runs. Without this, regenerating would drain the queue *and* leave
a third generation running.
- **Stopped.** Stop means stop, and the queue stays visible and
undelivered with Send now beside it. This is also what makes shutdown
safe -- cancellation sets `stopped`, so a restart never fires off a reply
with nobody watching.
- **Errored.** The endpoint has just failed. Feeding the next prompt into it
produces a second failure and spends somebody's words to do it.
"""
owner = _RUNNING.get(generation.message_id)
if owner is not None and owner is not generation:
return
if generation.stopped or generation.error:
return
try:
with session_scope() as db:
chat = db.get(Chat, generation.chat_id)
if chat is None:
return
waiting = _next_waiting(db, chat.id)
if waiting is None:
return
waiting.queued = False
assistant = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
)
chat_id, assistant_id = chat.id, assistant.id
except Exception: # noqa: BLE001 - the reply is over either way
log.exception("could not drain the queue for chat %s", generation.chat_id)
return
# Outside the session: this starts a task, and a task is not something to
# hold a database session open across.
ensure(chat_id, assistant_id)
generation.drained = True
def _inject(generation: Generation, chat_id: str, vision: bool) -> dict | None:
"""Take the oldest waiting prompt into this reply, between two rounds.
Marked delivered and committed *before* the request goes out, so this is
at-most-once. A crash in between loses the turn, which is recoverable --
the words are still in the transcript with Send now beside them. The other
way round would ask the same question twice and let an agent act on it
twice, which is not.
Sent verbatim, in the user role, with no framing. Everything else this
codebase injects is quoted and attributed because it came out of a file, a
page or a machine; this one genuinely *is* the person at the keyboard,
authenticated by the session cookie and stored as a `Message` whose role
says so. Wrapping it would teach a model that a user turn can be a
quotation, which is the exact distinction the other two rely on. What the
model needs -- that this can happen at all -- is one sentence in the
harness, where authored wording lives.
"""
try:
with session_scope() as db:
waiting = _next_waiting(db, chat_id)
if waiting is None:
return None
waiting.queued = False
entry = chat_service.message_payload(waiting, vision=vision)
# The reply that answers it must sort *before* it, or the next
# turn's transcript reads "answer, then the question it answered"
# and a small model dutifully answers again. Moving the placeholder
# rather than the prompt keeps several interjections in the order
# they were typed.
placeholder = db.get(Message, generation.message_id)
if placeholder is not None:
placeholder.created_at = datetime.now(UTC)
generation.injected_ids.append(waiting.id)
except Exception: # noqa: BLE001 - a lost interjection is not a failed reply
log.exception("could not take a queued prompt into chat %s", chat_id)
return None
generation.status = "Taking in what you just added…"
generation.touch()
return entry
def _persist(generation: Generation, title: str, elapsed: float) -> None:
"""Write the finished reply, name the chat, and set the unread flag.
+16
View File
@@ -587,6 +587,22 @@ BUILTIN: tuple[Fragment, ...] = (
"within that budget: two careful searches beat six that run out halfway."
),
),
Fragment(
key="core.interjection",
label="Being interrupted",
group=GROUP_CORE,
order=115,
when_tools=True,
hint="A message typed while you are working is handed to you between two "
"rounds of tool calls. Without this a model reads it as a fresh "
"conversation and starts the whole task again.",
default=(
"A new message from the person you are working for can arrive between "
"rounds of tool calls, while you are still working. Take it into account "
"from that point on. You do not need to start again or to re-explain what "
"you have already done — carry on, adjusted."
),
),
Fragment(
key="core.no_replay",
label="Results are not kept",
+42 -8
View File
@@ -576,6 +576,24 @@
.msg:focus-within .msg__actions { opacity: 1; }
.msg__actions .is-copied { color: var(--success); }
/* --- A turn that is waiting to be sent ------------------------------------
Its actions do not fade in on hover like the others: they are the only way
to withdraw something that has not happened yet, and a control you have to
find by hovering is one somebody will not find. */
.msg--queued { opacity: 0.75; }
.msg--queued .msg__body {
border-inline-start: 2px dashed var(--border-strong);
padding-inline-start: var(--sp-2);
}
.msg__actions--queued { opacity: 1; align-items: center; }
.msg__note {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
color: var(--ink-muted);
font-size: var(--text-xs);
}
/* --- Rendered Markdown ---------------------------------------------------- */
.msg__body > :first-child { margin-top: 0; }
.msg__body > :last-child { margin-bottom: 0; }
@@ -761,16 +779,32 @@
.composer__mirror .tok-mention,
.composer__mirror .tok-command {
border-radius: var(--radius-sm);
/* Bled sideways so the rectangle does not sit hard against the next word,
and the negative margin keeps the text metrics identical. */
padding: 0 2px;
margin: 0 -2px;
/* Restated rather than inherited. `color: transparent` on the mirror is an
inherited value, and a colour the span declares itself beats it -- which is
exactly what the transcript's rule below used to do from across the file,
painting the token in accent-coloured mono at 0.95em on top of the
textarea's own text. Doubled, and shifted from there on, because the
metrics differ. The font must be restated for the same reason. */
color: transparent;
font: inherit;
/* Bled sideways by a shadow, not by padding: a rectangle that spreads cannot
move a glyph, and the negative margin that used to do this was the only
thing in the mirror that could. */
padding: 0;
margin: 0;
}
.composer__mirror .tok-mention {
background: var(--accent-soft);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.composer__mirror .tok-command {
background: var(--leaf-soft);
box-shadow: 0 0 0 2px var(--leaf-soft);
}
.composer__mirror .tok-mention { background: var(--accent-soft); }
.composer__mirror .tok-command { background: var(--leaf-soft); }
/* The same two in a sent message, where they are text rather than a backdrop. */
.tok-mention {
/* The same two in a sent message, where they are text rather than a backdrop --
and scoped to it, because unscoped they also matched the mirror's spans. */
.msg .tok-mention {
border-radius: var(--radius-sm);
padding: 0 2px;
background: var(--accent-soft);
+42 -2
View File
@@ -75,7 +75,11 @@
name: "effort",
summary: "How hard a reasoning model should think",
argument: "low | medium | high",
when: function () { return !!chat() && !!el("[data-effort]"); },
/* Offered wherever there is a model, not only where the control is.
`available()` filters `find()` and `run()` as well as the menu, so a
command hidden here is not merely unlisted -- typing it in full stops
being a command and gets sent as a message. Better to answer. */
when: function () { return !!el('[name="model_id"]'); },
run: function (rest) { setEffort(rest); }
},
{
@@ -115,6 +119,12 @@
});
}
},
{
name: "index",
summary: "Read the project directory again",
when: function () { return !!chat() && isAgent(); },
run: function () { reindex(); }
},
{
name: "terminal",
summary: "Show or hide the terminal",
@@ -163,6 +173,32 @@
return function () { window.location = url; };
}
/* --- Reading the project directory again --------------------------------
The listing is cached for five minutes and only ever built when a reply
starts, so anything done in the terminal panel -- a checkout, a build --
is invisible to it until then. This is the "look again now". */
var indexing = false;
function reindex() {
if (indexing) return note("Already reading the project directory.");
indexing = true;
note("Reading the project directory…");
post("/api/chats/" + chat() + "/index")
.then(function (response) {
return response.json().then(function (body) {
return { ok: response.ok, body: body };
});
})
.then(function (result) {
if (!result.ok) {
return note(result.body.detail || "Could not read the project directory.", "error");
}
note(result.body.message);
})
.catch(function () { note("Could not read the project directory.", "error"); })
.finally(function () { indexing = false; });
}
/* --- Reasoning effort ---------------------------------------------------
The command drives the same select the composer shows, so there is one
piece of state and the control updates itself when the command is used. */
@@ -171,7 +207,11 @@
function setEffort(rest) {
var select = el("[data-effort]");
if (!select) {
return note("This model is not marked as a reasoning model.", "error");
return note(
"This model is not marked as a reasoning model, so effort would do " +
"nothing. An administrator can mark it on the model's page.",
"error"
);
}
var wanted = (rest || "").trim().toLowerCase();
if (!wanted) {
+18
View File
@@ -273,6 +273,24 @@
} else if (option.dataset.mentionKnowledge) {
body.append("document_id", option.dataset.mentionKnowledge);
attach("/api/files/from-knowledge", body);
} else if (option.dataset.mentionNote) {
body.append("note_id", option.dataset.mentionNote);
attach("/api/files/from-note", body);
} else if (option.dataset.mentionSkill) {
body.append("skill_id", option.dataset.mentionSkill);
attach("/api/files/from-skill", body);
} else if (option.dataset.mentionAttachment) {
body.append("attachment_id", option.dataset.mentionAttachment);
attach("/api/files/from-attachment", body);
} else if (option.dataset.mentionUrl) {
body.append("url", option.dataset.mentionUrl);
attach("/api/files/link", body);
} else if (option.dataset.mentionBase) {
/* Not an attachment: nothing is copied, and what changes is what this
chat is allowed to search. It goes on the chat, so the route is the
chat's own. */
body.append("base_id", option.dataset.mentionBase);
attach("/api/chats/" + where.chatId + "/bases", body);
}
}
+43 -18
View File
@@ -35,7 +35,11 @@
/* Whether this shell tells us where commands begin and end -- "live",
"loading" or "none". Everything the three buttons do keys off it. */
var integration = "loading";
var autoSend = false;
/* "off" | "copy" | "send". Three states rather than a boolean, because the
old one did the wrong one of them: it appended into the composer, on top of
whatever was being typed there. A select rather than a cycling button --
a button cannot say which of three states it is in. */
var autoMode = "off";
var lastCommand = null;
function say(text, isError) {
@@ -169,8 +173,12 @@
and the buttons fetch what they need when they are pressed. */
if (integration !== "live") { integration = "live"; applyIntegration(); }
showLast(payload.command);
if (autoSend) {
capture(true).then(function (text) { intoComposer(text, true); });
if (autoMode !== "off") {
capture(true).then(function (text) {
if (!text) return;
if (autoMode === "copy") return intoComposer(text, true);
sendStraightToChat(text);
});
}
return;
}
@@ -287,10 +295,10 @@
var usable = integration === "live";
auto.disabled = !usable;
auto.title = usable
? "Attach every command you run to your next message"
? "What to do with each command you run"
: "This shell did not load LLeMbas's command markers, so there is no way " +
"to tell where one command's output ends.";
if (!usable && autoSend) setAuto(false);
if (!usable && autoMode !== "off") setAuto("off");
}
function showLast(command) {
@@ -301,16 +309,32 @@
slot.textContent = lastCommand ? lastCommand.summary : "";
}
function setAuto(on) {
autoSend = !!on;
var button = panel.querySelector("[data-terminal-auto]");
if (button) {
button.setAttribute("aria-pressed", autoSend ? "true" : "false");
button.classList.toggle("is-active", autoSend);
var AUTO_SAID = {
off: "Commands are no longer attached automatically.",
copy: "Every command you run will be put into the message box.",
send: "Every command you run will be sent as a message on its own."
};
function setAuto(mode) {
autoMode = AUTO_SAID[mode] ? mode : "off";
var select = panel && panel.querySelector("[data-terminal-auto]");
if (select && select.value !== autoMode) select.value = autoMode;
say(AUTO_SAID[autoMode]);
}
say(autoSend
? "Every command you run will be attached to your next message."
: "Commands are no longer attached automatically.");
/* Sent, not typed. The composer is left entirely alone -- somebody may be
half-way through a sentence in it, and overwriting that is the complaint
this replaces. The thread receives whatever the server decides the message
is: a streaming pair, or a single queued bubble if a reply is already being
written. Nothing here needs to know which. */
function sendStraightToChat(text) {
var url = panel.dataset.url.replace(/\/terminal\/ws$/, "/messages");
if (!window.htmx) return;
window.htmx.ajax("POST", url, {
target: "#thread",
swap: "beforeend",
values: { content: text }
});
}
/* A selection always wins, in every state. People rely on it, and it is the
@@ -427,10 +451,11 @@
event.preventDefault();
return copyToClipboard();
}
if (event.target.closest("[data-terminal-auto]")) {
event.preventDefault();
return setAuto(!autoSend);
}
});
panel.addEventListener("change", function (event) {
var select = event.target.closest("[data-terminal-auto]");
if (select) setAuto(select.value);
});
/* xterm holds colours as values, not as variables, so a theme change has
@@ -0,0 +1,19 @@
{% from "_macros.html" import icon %}
{#
A knowledge base attached from the `@` menu.
Deliberately not an attachment chip: nothing was copied and there is no
`file_ids` input to submit. The base is already on the chat, and what it does
is narrow what `knowledge_search` may see -- so this says so, and says it in
the present tense, because it is already in force before the message is sent.
No remove button. Removing one is a checkbox in the chat's settings, where
the whole set is visible at once rather than only whichever was added last.
#}
<div class="attach-chip attach-chip--base" id="base-chip-{{ base.id }}">
<span class="attach-chip__icon">{{ icon("archive", "icon--sm") }}</span>
<span class="attach-chip__body">
<span class="attach-chip__name" title="{{ base.name }}">{{ base.name }}</span>
<span class="attach-chip__meta">This chat now searches only the bases it is attached to</span>
</span>
</div>
+37 -16
View File
@@ -215,10 +215,16 @@
they were taking a slot in a row that has work to do.
Its own form: nesting one inside the composer's form is invalid HTML
and the browser drops the inner one. #}
and the browser drops the inner one.
The verb is on the select, not on that form. htmx binds a trigger to
the annotated element itself, and `change` fires here and bubbles
through this element's *ancestors* -- which a sibling form is not.
`form=` scopes the values, and only the values. #}
<div class="composer__context">
<select class="select select--sm" name="agent_mode" aria-label="Approval mode"
form="agent-mode-form">
form="agent-mode-form"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
{% for value, label, hint in agent_modes %}
<option value="{{ value }}" title="{{ hint }}"
{{ 'selected' if value == chat.agent_mode }}>{{ label }}</option>
@@ -234,17 +240,27 @@
Only on a model an administrator has marked as **reasoning**: that
flag has existed since the beginning with no reader at all, and
offering the control everywhere would be offering a setting that does
nothing almost everywhere. Its own form, for the reason the mode has
one -- a form cannot nest inside another.
nothing almost everywhere.
On an existing chat it writes on change, and needs the same treatment
the mode select gets: the verb on the control, `form=` for the values.
Before there is a chat there is nothing to PATCH, so it is an ordinary
field of the composer's own form and `_new_chat` reads it -- which is
what makes the setting choosable before the first prompt rather than
after it.
#}
{% if chat and current_model and current_model.capabilities_json.get("reasoning") %}
{% if current_model and current_model.capabilities_json.get("reasoning") %}
<select class="select select--sm" name="reasoning_effort" data-effort
aria-label="Reasoning effort" title="How hard this model should think"
form="chat-params-form">
{% if chat %}
form="chat-params-form"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
{% endif %}>
{% set chosen = chat.params_json.get('reasoning_effort') if chat
else (current_model.params_json or {}).get('reasoning_effort') %}
<option value="">Effort: default</option>
{% for value in efforts %}
<option value="{{ value }}"
{{ 'selected' if chat.params_json.get('reasoning_effort') == value }}>
<option value="{{ value }}" {{ 'selected' if chosen == value }}>
Effort: {{ value }}
</option>
{% endfor %}
@@ -286,17 +302,22 @@
</div>
</form>
{# Outside the composer's form, and referenced by the mode select's `form`
attribute above. hx-patch and not hx-post: there is no POST for a chat,
only PATCH, and htmx shows nothing when a request 405s -- which is how
this control spent its whole life doing nothing. #}
{# Outside the composer's form, and referenced by the two selects' `form`
attributes above. These carry no htmx of their own: they exist so that
`Nt(e)` -- htmx's "which form do the values come from", which reads
`e.form` before falling back to `closest("form")` -- resolves to a form
holding exactly one control. Without them the PATCH would carry the
composer's `content`, `model_id` and `project_dir`, and `update_chat`
answers `project_dir` with a 409.
hx-patch and not hx-post: there is no POST for a chat, only PATCH, and
htmx shows nothing when a request 405s -- which is how these controls
spent the first half of their lives doing nothing. #}
{% if chat and chat.kind == "agent" %}
<form id="agent-mode-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
hx-trigger="change"></form>
<form id="agent-mode-form"></form>
{% endif %}
{% if chat %}
<form id="chat-params-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
hx-trigger="change"></form>
<form id="chat-params-form"></form>
{% endif %}
<p class="composer__hint">
@@ -11,7 +11,8 @@
all of it is escaped by autoescaping and none of it is marked safe.
#}
<div id="mention-results">
{% if not files and not documents %}
{% if not files and not documents and not notes and not skills
and not bases and not attachments and not website %}
<p class="muted text-sm" style="padding: var(--sp-3)">
{% if q %}
Nothing matches “{{ q }}”.
@@ -21,6 +22,22 @@
</p>
{% else %}
{% if website %}
<p class="picker__group">A page to read</p>
<ul class="picker__list">
<li>
<button class="picker__option" type="button"
data-mention-url="{{ website }}" data-mention-token="{{ website }}">
{{ icon("link", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">Fetch this page</span>
<span class="picker__option-note mono">{{ website }}</span>
</span>
</button>
</li>
</ul>
{% endif %}
{% if files %}
<p class="picker__group">In the project</p>
<ul class="picker__list">
@@ -61,5 +78,82 @@
</ul>
{% endif %}
{% if notes %}
<p class="picker__group">Notes</p>
<ul class="picker__list">
{% for note in notes %}
<li>
<button class="picker__option" type="button"
data-mention-note="{{ note.id }}" data-mention-token="{{ note.title }}">
{{ icon("file-text", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">{{ note.title }}</span>
<span class="picker__option-note">
{{ note.body | truncate(70) }}{% if note.owner_id != user.id %} · shared{% endif %}
</span>
</span>
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% if skills %}
<p class="picker__group">Skills</p>
<ul class="picker__list">
{% for skill in skills %}
<li>
<button class="picker__option" type="button"
data-mention-skill="{{ skill.id }}" data-mention-token="{{ skill.name }}">
{{ icon("sparkle", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name mono">{{ skill.name }}</span>
<span class="picker__option-note">{{ skill.description }}</span>
</span>
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% if bases %}
{# A base is a reference and not a copy: choosing one narrows what this chat
may search rather than putting anything into the message. #}
<p class="picker__group">Search only these</p>
<ul class="picker__list">
{% for base in bases %}
<li>
<button class="picker__option" type="button"
data-mention-base="{{ base.id }}" data-mention-token="{{ base.name }}">
{{ icon("archive", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">{{ base.name }}</span>
<span class="picker__option-note">Scope this chat to this knowledge base</span>
</span>
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% if attachments %}
<p class="picker__group">Already in this chat</p>
<ul class="picker__list">
{% for attachment in attachments %}
<li>
<button class="picker__option" type="button"
data-mention-attachment="{{ attachment.id }}"
data-mention-token="{{ attachment.filename }}">
{{ icon("attach", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">{{ attachment.filename }}</span>
<span class="picker__option-note">{{ attachment.human_size }}</span>
</span>
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% endif %}
</div>
+27 -2
View File
@@ -13,8 +13,16 @@
escaped plain text for everyone else.
#}
{% set streaming = (message.role == "assistant" and not message.complete) %}
{#
Typed while the previous reply was still being written, and not yet handed to
a model. It is in the transcript and it is not in the request. It must never
carry `sse-connect` -- a queued turn with a streaming shell on it would be the
second concurrent reply the queue exists to prevent.
#}
{% set queued = (message.role == "user" and message.queued) %}
<article class="msg msg--{{ message.role }}" id="msg-{{ message.id }}"
<article class="msg msg--{{ message.role }}{{ ' msg--queued' if queued }}"
id="msg-{{ message.id }}"
{% if streaming %}
hx-ext="sse"
sse-connect="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stream"
@@ -210,7 +218,24 @@
</div>
{% endif %}
{% if not streaming %}
{% if queued %}
{# Nothing has been sent to any model. Both buttons sit on the bubble they
act on rather than in a toast somewhere, and Edit is deliberately absent:
editing rewinds and then starts a reply, which on a row you can press
while another reply is streaming is a second concurrent generation behind
a pencil. Discard and retype is the honest affordance. #}
<footer class="msg__actions msg__actions--queued">
<span class="msg__note">{{ icon("clock", "icon--sm") }} Waiting to be sent</span>
<button class="btn btn--sm" type="button"
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/send-now"
hx-target="#thread" hx-swap="innerHTML">Send now</button>
<button class="btn btn--sm" type="button"
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/discard"
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"
data-confirm-button="Discard this message?">Discard</button>
</footer>
<div hidden id="msg-body-{{ message.id }}">{{ message.content }}</div>
{% elif not streaming %}
<footer class="msg__actions">
<button class="btn btn--icon btn--sm" type="button"
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
+15 -6
View File
@@ -50,12 +50,21 @@
aria-label="Send to chat">
{{ icon("arrow-up", "icon--sm") }}
</button>
<button class="btn btn--icon btn--sm" type="button" data-terminal-auto
aria-pressed="false"
title="Attach every command you run to your next message"
aria-label="Send every command automatically">
{{ icon("sparkle", "icon--sm") }}
</button>
{#
Three states, not two. A cycling icon button cannot say which of three it
is in, and this one decides whether things are sent to a model without
being asked again -- so it says so in words.
Not remembered between page loads on purpose: a switch that forwards every
command you run to a model is not something to inherit from last week.
#}
<select class="select select--sm" data-terminal-auto
aria-label="What to do when a command finishes"
title="What to do with each command you run">
<option value="off" selected>Auto: off</option>
<option value="copy">Auto: copy</option>
<option value="send">Auto: send</option>
</select>
<button class="btn btn--icon btn--sm" type="button" data-toggle="#terminal"
aria-label="Close terminal">
{{ icon("x", "icon--sm") }}
+26
View File
@@ -209,6 +209,32 @@ def mock_http():
httpx.AsyncClient = original
def control_named(html: str, name: str) -> dict[str, str]:
"""The attributes of the one element carrying `name=""`.
Exists so a test can ask "does the control that carries the name also carry
the verb?". Two selects in the composer once delegated their `hx-patch` to
an empty sibling form through the `form=` attribute, which scopes values but
routes no events -- htmx binds a trigger to the annotated element, and
`change` reaches ancestors, never siblings. Both controls were decorative
for a whole release, and the tests passed the entire time because they
asserted the markup that was there rather than the property that mattered.
"""
from html.parser import HTMLParser
found: list[dict[str, str]] = []
class Finder(HTMLParser):
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
got = {key: (value or "") for key, value in attrs}
if got.get("name") == name:
found.append(got)
Finder().feed(html)
assert len(found) == 1, f"expected one element named {name!r}, found {len(found)}"
return found[0]
@pytest.fixture
def user_id(db: Session, registered: dict[str, str]) -> str:
"""The registered user's id.
+75
View File
@@ -254,3 +254,78 @@ def test_somebody_elses_connection_is_not_browsable(
# 404 and not 403: whether that connection exists is not this endpoint's to
# reveal to somebody who does not own it.
assert client.get(f"/api/agents/{profile.id}/browse").status_code == 404
# --- Reading the project directory again -------------------------------------
def _agent_chat(db, profile):
from lembas.db.models import KIND_AGENT, Chat, 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", capabilities_json={"tools": True}))
db.commit()
chat = Chat(
user_id=profile.owner_id,
model_id="m",
connection_id=connection.id,
kind=KIND_AGENT,
ssh_profile_id=profile.id,
project_dir=profile.default_dir,
)
db.add(chat)
db.commit()
return chat
def test_reindexing_walks_the_tree_again(client: TestClient, db, registered, served_tree):
"""The listing is built only when a reply starts and then held for five
minutes, so anything done in the terminal panel is invisible to it until
then. This is the way to say "look again"."""
from lembas.services.agent import index as index_service
profile = _profile(db, served_tree)
chat = _agent_chat(db, profile)
response = client.post(f"/api/chats/{chat.id}/index")
assert response.status_code == 200, response.text
body = response.json()
assert body["ok"] is True
assert body["files"] >= 2 # README.md and src/
assert index_service.cached(profile.id, served_tree["root"]) is not None
def test_reindexing_a_plain_chat_says_there_is_nothing_to_read(
client: TestClient, db, registered, served_tree
):
from lembas.db.models import Chat, 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()
chat = Chat(user_id=_profile(db, served_tree).owner_id, model_id="m",
connection_id=connection.id)
db.add(chat)
db.commit()
assert client.post(f"/api/chats/{chat.id}/index").status_code == 409
def test_reindexing_somebody_elses_chat_is_not_possible(
client: TestClient, db, registered, served_tree
):
profile = _profile(db, served_tree)
chat = _agent_chat(db, profile)
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
assert client.post(f"/api/chats/{chat.id}/index").status_code == 404
+37
View File
@@ -180,6 +180,43 @@ async def test_forgetting_a_connection_drops_its_listings():
assert index_service.cached("profile-2", "/work") is not None
async def test_a_host_that_refuses_to_run_commands_still_gets_a_listing():
"""SFTP is the rung for exactly this, and the ladder used to skip it.
An `ExecError` from git or find -- an SFTP-only account, a forced command,
a shell that is `/bin/false` -- escaped the loop and was caught outside it,
which returned an empty index without ever trying the one method that would
have worked.
"""
class _NoExec(_Fake):
async def run(self, request):
raise ExecError("This account may not run commands.")
executor = _NoExec(tree={"/work": [RemoteEntry("README.md", False, 5)]})
found = await index_service.build(executor, "/work")
assert found.source == "sftp"
assert "README.md" in found.paths
async def test_forgetting_one_tree_leaves_the_others():
"""What a write invalidates is the directory it wrote into, not the machine.
Two chats on one box in different trees share nothing but the connection,
and dropping both would make every write cost somebody else a walk.
"""
executor = _Fake(answers={"git ls-files": _ok("a.py\n")})
await index_service.ensure(executor, "profile-1", "/work")
await index_service.ensure(executor, "profile-1", "/other")
index_service.forget_dir("profile-1", "/work")
assert index_service.cached("profile-1", "/work") is None
assert index_service.cached("profile-1", "/other") is not None
def test_reading_the_cache_never_does_work():
"""`harness` calls this synchronously while assembling the system message,
so it must never be the thing that opens a connection."""
+19 -11
View File
@@ -18,6 +18,8 @@ from lembas.db.models import KIND_AGENT, Chat, Connection, Model, SshProfile, Us
from lembas.services import settings_store
from lembas.services.agent import policy
from .conftest import control_named
def _agent_chat(db, *, mode: str = policy.MODE_MANUAL) -> Chat:
"""An agent chat pointed at a profile that is never actually connected to.
@@ -93,22 +95,28 @@ def test_the_mode_form_uses_a_method_the_route_serves(client: TestClient, db, re
assert chat.agent_mode == policy.MODE_MANUAL
def test_the_rendered_form_patches(client: TestClient, db, registered):
"""And that the template actually carries it, since that is where it broke.
def test_the_mode_select_carries_its_own_verb(client: TestClient, db, registered):
"""The request must hang off the element the event fires on.
Pinned to the mode form by its id rather than to any `hx-patch` on the
page: the model picker and the system-prompt box patch the same URL, so a
looser assertion would have passed throughout the entire life of the bug.
This is the invariant, and the previous version of this test did not check
it. `hx-patch` lived on an empty sibling `<form>` that the select pointed at
with `form=""`, which was enough to make the markup look right and enough
to make every assertion here pass -- while htmx bound the `change` listener
to the form, and `change` fires on the select and bubbles to its *ancestors*
only. The mode never once reached the database.
So: assert on the control, by name, whichever element that turns out to be.
"""
chat = _agent_chat(db)
body = client.get(f"/chat/{chat.id}").text
assert 'id="agent-mode-form"' in body
form = body[body.index('id="agent-mode-form"') :][:200]
assert f'hx-patch="/api/chats/{chat.id}"' in form
assert "hx-post" not in form
# And that the select outside it is actually submitted by it.
assert 'form="agent-mode-form"' in body
select = control_named(body, "agent_mode")
assert select["hx-patch"] == f"/api/chats/{chat.id}"
assert "hx-post" not in select
# And the empty form still scopes the values, so the PATCH carries this
# field alone rather than the whole composer -- `project_dir` in a PATCH is
# a 409.
assert select["form"] == "agent-mode-form"
def test_the_mode_is_offered_beside_the_composer_not_in_the_topbar(
+160
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
import json as _json
import time
import pytest
@@ -205,6 +206,31 @@ async def test_files_are_written_and_read_back(db, user_id, machine, tmp_path):
assert "note.txt" in listed.content
async def test_writing_a_file_drops_the_project_listing(db, user_id, machine):
"""Otherwise the model is shown a five-minute-old tree that it knows is
wrong, and concludes the file it has just created does not exist.
The TTL is for drift nobody can see coming. This is not that: it is this
process changing the tree it has just described.
"""
from lembas.services.agent import index as index_service
chat, profile = _setup(db, user_id, machine, mode=policy.MODE_AUTO)
user = db.get(User, user_id)
resolved = tools_service.resolve_tools(db, chat, user)
context = tools_service.context_for(db, user, chat, tools=resolved)
index_service._CACHE[(profile.id, machine["dir"])] = index_service.ProjectIndex(
paths=("stale.txt",), total=1, source="git", built_at=time.monotonic()
)
await tools_service.run_tool(
context, "file_write", '{"path": "fresh.txt", "content": "hi"}'
)
assert index_service.cached(profile.id, machine["dir"]) is None
# --- The runner backstop ----------------------------------------------------------
async def test_a_runner_refuses_what_the_mode_forbids(db, user_id, machine):
"""`_authorise` is the real gate and runs first. This is the belt to that
@@ -486,6 +512,140 @@ async def test_an_agent_chat_is_told_its_real_round_budget(db, user_id, machine)
assert values["max_rounds"] == "25"
async def test_an_agent_chat_gets_the_rounds_it_was_promised(db, user_id, machine, monkeypatch):
"""And is then allowed to use them, which is the half that was missing.
The budget sizes the loop and names itself in the out-of-rounds message, and
the harness above tells the model the same number. But the comparison that
ends the loop read the global `MAX_ROUNDS` of three. So an agent chat
allowed forty rounds stopped after three and reported that it had taken
forty: two wrong answers to "why did it stop", with no way to tell them
apart from the outside.
"""
settings_store.update(db, {"max_steps": 5}, key=settings_store.AGENTS)
chat, _profile = _setup(db, user_id, machine, mode=policy.MODE_AUTO)
assistant = Message(chat_id=chat.id, role=ROLE_ASSISTANT, content="", complete=False)
db.add(assistant)
db.commit()
payloads: list[dict] = []
async def stream_chat(_endpoint, payload):
payloads.append(payload)
yield {
"choices": [
{"delta": {"tool_calls": [{"index": 0, "id": "c1", "function": {
"name": "file_list", "arguments": '{"path": "."}'}}]}}
]
}
monkeypatch.setattr(generation_service, "stream_chat", stream_chat)
async def _no_title(*_args, **_kwargs):
return ""
monkeypatch.setattr("lembas.services.chat.generate_title", _no_title)
generation = generation_service.Generation(chat_id=chat.id, message_id=assistant.id)
await generation_service._run(generation)
# Five rounds that may call tools, then the one that gives up.
assert len(payloads) == 6
assert "after 5 rounds" in generation.tool_events[-1]["error"]
# --- Interjecting while it works --------------------------------------------------
async def test_a_queued_prompt_is_taken_in_between_rounds(db, user_id, machine, monkeypatch):
"""The point of queueing in an agent chat: steering work already under way.
An agent that has just finished one loop and is about to start another is
exactly when "actually, do it the other way" is worth having, and making it
wait for the whole reply would mean it arrives after the thing it was meant
to change.
"""
from lembas.services import chat as chat_service
chat, _profile = _setup(db, user_id, machine, mode=policy.MODE_AUTO)
message_id = _pending_reply(db, chat)
chat_service.create_message(
db, chat, "user", "actually, check the other directory first", queued=True
)
payloads: list[dict] = []
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[[_chunk("file_list", '{"path": "."}')], [_text("Done.")]],
payloads,
),
)
generation = generation_service.Generation(chat_id=chat.id, message_id=message_id)
await generation_service._run(generation)
# Verbatim, in the user role, with nothing wrapped around it: this genuinely
# is the person at the keyboard, and quoting it would teach the model that a
# user turn can be a quotation -- the distinction `execute_plan` relies on.
assert payloads[1]["messages"][-1] == {
"role": "user",
"content": "actually, check the other directory first",
}
async def test_an_interjection_is_delivered_only_once(db, user_id, machine, monkeypatch):
"""Marked delivered before the request goes out, so a crash loses it rather
than asking the same thing twice and letting an agent act on it twice."""
from lembas.services import chat as chat_service
chat, _profile = _setup(db, user_id, machine, mode=policy.MODE_AUTO)
message_id = _pending_reply(db, chat)
waiting = chat_service.create_message(db, chat, "user", "one more thing", queued=True)
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[
[_chunk("file_list", '{"path": "."}')],
[_chunk("file_list", '{"path": "src"}')],
[_text("Done.")],
],
[],
),
)
generation = generation_service.Generation(chat_id=chat.id, message_id=message_id)
await generation_service._run(generation)
db.expire_all()
assert db.get(Message, waiting.id).queued is False
assert generation.injected_ids == [waiting.id]
async def test_the_reply_sorts_before_the_prompt_it_took_in(db, user_id, machine, monkeypatch):
"""Otherwise the next request reads "answer, then the question it answered",
and a small model dutifully answers it a second time."""
from lembas.services import chat as chat_service
chat, _profile = _setup(db, user_id, machine, mode=policy.MODE_AUTO)
message_id = _pending_reply(db, chat)
waiting = chat_service.create_message(db, chat, "user", "and this", queued=True)
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream([[_chunk("file_list", '{"path": "."}')], [_text("Done.")]], []),
)
generation = generation_service.Generation(chat_id=chat.id, message_id=message_id)
await generation_service._run(generation)
db.expire_all()
reply = db.get(Message, message_id)
assert reply.created_at > db.get(Message, waiting.id).created_at
# --- Plan mode's artifact ---------------------------------------------------------
def test_plan_submit_is_offered_only_in_plan_mode(db, user_id, machine):
"""It ends the reply. A model in Auto mode that proposed a plan instead of
+68
View File
@@ -17,6 +17,8 @@ from sqlalchemy import select
from lembas.db.models import Chat, Connection, Model, User
from lembas.services import chat as chat_service
from .conftest import control_named
def _model(db, **capabilities) -> Model:
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
@@ -162,3 +164,69 @@ def test_a_model_with_no_defaults_starts_a_plain_chat(client: TestClient, db, re
client.post("/api/chats/start", data={"content": "hello", "model_id": "m"})
assert db.scalars(select(Chat)).one().params_json == {}
# --- Choosable before the first prompt ---------------------------------------
def test_the_effort_select_carries_its_own_verb(client: TestClient, db, registered):
"""The same invariant the mode select needs, for the same reason.
Both were built on `form=""` pointing at an empty sibling form holding the
`hx-patch`, and both therefore wrote nothing at all: `form=` scopes the
values a request carries, it does not route the event that starts one.
"""
chat = _chat(db)
select = control_named(client.get(f"/chat/{chat.id}").text, "reasoning_effort")
assert select["hx-patch"] == f"/api/chats/{chat.id}"
assert select["form"] == "chat-params-form"
def test_the_effort_is_offered_before_there_is_a_chat(client: TestClient, db, registered):
"""Otherwise it is a setting you can only reach once it is too late to use.
On the new-chat screen there is nothing to PATCH, so it is an ordinary field
of the composer's form and carries no verb -- `_new_chat` reads it.
"""
_model(db)
select = control_named(client.get("/chat").text, "reasoning_effort")
assert "hx-patch" not in select
assert "form" not in select
def test_starting_a_chat_with_an_effort_stores_it(client: TestClient, db, registered):
_model(db)
client.post(
"/api/chats/start",
data={"content": "hello", "model_id": "m", "reasoning_effort": "low"},
)
assert db.scalars(select(Chat)).one().params_json["reasoning_effort"] == "low"
def test_an_explicit_effort_beats_the_models_default(client: TestClient, db, registered):
"""An inherited value is a starting point, not a ceiling."""
model = _model(db)
model.params_json = {"reasoning_effort": "high"}
db.commit()
client.post(
"/api/chats/start",
data={"content": "hello", "model_id": "m", "reasoning_effort": "low"},
)
assert db.scalars(select(Chat)).one().params_json["reasoning_effort"] == "low"
def test_a_nonsense_effort_at_the_start_falls_back(client: TestClient, db, registered):
model = _model(db)
model.params_json = {"reasoning_effort": "high"}
db.commit()
client.post(
"/api/chats/start",
data={"content": "hello", "model_id": "m", "reasoning_effort": "extreme"},
)
assert db.scalars(select(Chat)).one().params_json["reasoning_effort"] == "high"
+137
View File
@@ -152,6 +152,143 @@ def test_a_plain_chat_gets_a_picker_with_no_file_half(client: TestClient, db, re
assert "In the project" not in response.text
# --- What `@` offers where there is no machine at all ------------------------
def _library(db):
"""A note, a skill and a base belonging to the registered reader."""
from lembas.db.models import KnowledgeBase, Note, Skill
owner = db.scalars(select(User)).first()
note = Note(owner_id=owner.id, title="Mallorn notes", body="Golden leaves.")
skill = Skill(
owner_id=owner.id, name="bake-lembas", description="How to bake it", body="Steps."
)
base = KnowledgeBase(owner_id=owner.id, name="Contracts")
db.add_all([note, skill, base])
db.commit()
return note, skill, base
def test_notes_and_skills_are_offered_in_a_plain_chat(client: TestClient, db, registered):
"""A chat with no SSH connection has no project files, which is exactly why
the rest of the library has to be reachable there."""
_library(db)
body = client.get("/api/files/mention-picker", params={"q": "mallorn"}).text
assert "Mallorn notes" in body
body = client.get("/api/files/mention-picker", params={"q": "lembas"}).text
assert "bake-lembas" in body
def test_a_knowledge_base_is_only_offered_inside_a_chat(
client: TestClient, db, registered, make_chat
):
"""There is nothing to attach it to before a chat exists -- the same reason
project files are absent on the new-chat screen."""
_library(db)
chat_id = make_chat()
assert "Contracts" not in client.get("/api/files/mention-picker").text
assert "Contracts" in client.get(
"/api/files/mention-picker", params={"chat_id": chat_id}
).text
def test_a_url_is_offered_as_a_page_to_read(client: TestClient, db, registered):
body = client.get(
"/api/files/mention-picker", params={"q": "https://tolkien.test/mallorn"}
).text
assert "Fetch this page" in body
assert "https://tolkien.test/mallorn" in body
def test_a_note_arrives_with_its_text_and_its_name(client: TestClient, db, registered, make_chat):
note, _skill, _base = _library(db)
chat_id = make_chat()
response = client.post(
"/api/files/from-note", data={"note_id": note.id, "chat_id": chat_id}
)
assert response.status_code == 200
attachment = db.scalars(select(Attachment)).one()
assert "Golden leaves." in attachment.extracted_text
# Provenance, for the reason a project file carries it: a model handed four
# documents cannot name one back when asked to work on it.
assert attachment.source_label == "Note"
assert attachment.source_path == "Mallorn notes"
def test_a_skill_can_be_handed_over_directly(client: TestClient, db, registered, make_chat):
"""The index is in the harness and `skill_get` fetches on demand -- but only
if the model decides to. `@` is the reader saying "use this one"."""
_note, skill, _base = _library(db)
chat_id = make_chat()
client.post("/api/files/from-skill", data={"skill_id": skill.id, "chat_id": chat_id})
attachment = db.scalars(select(Attachment)).one()
assert attachment.source_label == "Skill"
assert "Steps." in attachment.extracted_text
def test_a_base_is_attached_as_a_reference_not_a_copy(
client: TestClient, db, registered, make_chat
):
"""Scoping, not copying. A folder of contracts in the window would cost the
context on every request forever to answer one question."""
from lembas.db.models import Chat
_note, _skill, base = _library(db)
chat_id = make_chat()
response = client.post(f"/api/chats/{chat_id}/bases", data={"base_id": base.id})
assert response.status_code == 200
db.expire_all()
assert [b.id for b in db.get(Chat, chat_id).knowledge_bases] == [base.id]
# Nothing was copied into the message.
assert db.scalars(select(Attachment)).all() == []
def test_attaching_the_same_base_twice_is_not_an_error(
client: TestClient, db, registered, make_chat
):
from lembas.db.models import Chat
_note, _skill, base = _library(db)
chat_id = make_chat()
client.post(f"/api/chats/{chat_id}/bases", data={"base_id": base.id})
client.post(f"/api/chats/{chat_id}/bases", data={"base_id": base.id})
db.expire_all()
assert len(db.get(Chat, chat_id).knowledge_bases) == 1
def test_a_library_document_now_carries_its_provenance(client: TestClient, db, registered):
"""This was the one attach path that dropped it, while a project file beside
it carried path and machine."""
from lembas.services.fetch import Fetched
from lembas.services.library import documents as documents_service
owner = db.scalars(select(User)).first()
base = documents_service.default_base(db, owner)
document = documents_service.store_page(
db,
owner=owner,
base=base,
page=Fetched(url="https://tolkien.test/c", title="The Contract", text="Terms."),
)
client.post("/api/files/from-knowledge", data={"document_id": document.id})
attachment = db.scalars(select(Attachment)).one()
assert attachment.source_path == "The Contract"
assert attachment.source_label == base.name
# --- Attaching ---------------------------------------------------------------
def test_a_mentioned_file_arrives_with_its_contents(client: TestClient, db, registered, box):
profile = _profile(db, box)
+393
View File
@@ -0,0 +1,393 @@
"""Typing while a reply is being written.
Before this existed, a second message during a stream was simply accepted: it
wrote a second assistant placeholder, started a second `Generation`, and left
two replies answering the same chat from two different prefixes of it -- with
Stop pointing at whichever bubble came first in the document.
Now it queues. The queue is not an object: it is "the rows in this chat with
`queued` set, oldest first". That is the whole reason it survives a restart and
the reason `_prune` cannot take it away.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.api.chats import MAX_QUEUED
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Connection, Message, Model
from lembas.services import chat as chat_service
from lembas.services import generation as generation_service
from lembas.services.crypto import encrypt
@pytest.fixture(autouse=True)
def empty_registry():
yield
generation_service._RUNNING.clear()
generation_service._TASKS.clear()
@pytest.fixture
def no_upstream(monkeypatch):
"""Replace the producer, so the routes can be driven without a server."""
started: list = []
async def _fake_run(generation):
started.append(generation)
monkeypatch.setattr(generation_service, "_run", _fake_run)
return started
def _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 _mid_reply(db, chat_id: str) -> Message:
"""A chat with a reply still being written, which is the whole precondition."""
db.add(Message(chat_id=chat_id, role=ROLE_USER, content="what is lembas?"))
reply = Message(chat_id=chat_id, role=ROLE_ASSISTANT, content="Way", complete=False)
db.add(reply)
db.commit()
return reply
def _messages(db, chat_id: str) -> list[Message]:
return list(
db.scalars(
select(Message).where(Message.chat_id == chat_id).order_by(Message.created_at)
)
)
# --- The column --------------------------------------------------------------
def test_an_ordinary_message_is_not_queued(db, registered, make_chat):
"""Every row that predates the column reads the same way, because
`sync_schema` adds a NOT NULL boolean with a literal default of 0."""
chat_id = make_chat()
chat = db.get(Chat, chat_id)
assert chat_service.create_message(db, chat, ROLE_USER, "hello").queued is False
def test_a_queued_message_is_left_out_of_the_request(db, registered, make_chat):
"""It is in the transcript and it is not in the request. That distinction is
the entire feature."""
chat_id = make_chat()
chat = db.get(Chat, chat_id)
chat_service.create_message(db, chat, ROLE_USER, "first")
chat_service.create_message(db, chat, ROLE_ASSISTANT, "an answer")
chat_service.create_message(db, chat, ROLE_USER, "typed while it worked", queued=True)
sent = chat_service.build_messages(db, chat)
assert [m["content"] for m in sent] == ["first", "an answer"]
def test_a_delivered_message_is_sent_on_the_next_turn(db, registered, make_chat):
chat_id = make_chat()
chat = db.get(Chat, chat_id)
chat_service.create_message(db, chat, ROLE_USER, "first")
waiting = chat_service.create_message(db, chat, ROLE_USER, "second", queued=True)
waiting.queued = False
db.commit()
assert [m["content"] for m in chat_service.build_messages(db, chat)] == ["first", "second"]
# --- Queueing ----------------------------------------------------------------
def test_a_message_sent_during_a_reply_is_queued(
client: TestClient, db, registered, make_chat, no_upstream
):
"""The bug this replaces: a second POST used to start a second generation."""
_connection(db)
chat_id = make_chat()
_mid_reply(db, chat_id)
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "and also this"})
assert response.status_code == 200
rows = _messages(db, chat_id)
assert rows[-1].content == "and also this"
assert rows[-1].queued is True
# Exactly one reply in flight, which is the point.
assert len([m for m in rows if m.role == ROLE_ASSISTANT and not m.complete]) == 1
def test_a_queued_bubble_never_carries_a_streaming_shell(
client: TestClient, db, registered, make_chat, no_upstream
):
"""`sse-connect` is the only thing that starts a generation, so a queued
turn carrying one would be the second concurrent reply all over again.
Asserted on the body rather than on a row, unusually and deliberately: the
attribute *is* the behaviour here.
"""
_connection(db)
chat_id = make_chat()
_mid_reply(db, chat_id)
body = client.post(f"/api/chats/{chat_id}/messages", data={"content": "later"}).text
assert "sse-connect" not in body
assert "Waiting to be sent" in body
def test_a_message_with_nothing_in_flight_is_sent_as_before(
client: TestClient, db, registered, make_chat, no_upstream
):
_connection(db)
chat_id = make_chat()
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "hello"})
assert "sse-connect" in response.text
rows = _messages(db, chat_id)
assert rows[0].queued is False
assert rows[1].role == ROLE_ASSISTANT and rows[1].complete is False
def test_the_queue_is_bounded(client: TestClient, db, registered, make_chat, no_upstream):
"""A `for` loop in the terminal panel with Auto send on can produce commands
far faster than any model answers them."""
_connection(db)
chat_id = make_chat()
_mid_reply(db, chat_id)
for n in range(MAX_QUEUED):
assert client.post(
f"/api/chats/{chat_id}/messages", data={"content": f"line {n}"}
).status_code == 200
refused = client.post(f"/api/chats/{chat_id}/messages", data={"content": "one too many"})
assert refused.status_code == 409
assert len([m for m in _messages(db, chat_id) if m.queued]) == MAX_QUEUED
# --- Delivery ----------------------------------------------------------------
async def test_a_finished_reply_delivers_the_next_prompt(
db, registered, make_chat, no_upstream
):
_connection(db)
chat_id = make_chat()
reply = _mid_reply(db, chat_id)
chat = db.get(Chat, chat_id)
waiting = chat_service.create_message(db, chat, ROLE_USER, "next please", queued=True)
generation = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
generation_service._drain(generation)
db.refresh(waiting)
assert waiting.queued is False
assert generation.drained is True
assert len([m for m in _messages(db, chat_id) if not m.complete]) == 2
async def test_only_one_prompt_is_delivered_at_a_time(db, registered, make_chat, no_upstream):
"""Draining the lot would put two consecutive user turns in the next
request, which several local chat templates refuse outright."""
_connection(db)
chat_id = make_chat()
reply = _mid_reply(db, chat_id)
chat = db.get(Chat, chat_id)
first = chat_service.create_message(db, chat, ROLE_USER, "one", queued=True)
second = chat_service.create_message(db, chat, ROLE_USER, "two", queued=True)
generation_service._drain(
generation_service.Generation(chat_id=chat_id, message_id=reply.id)
)
db.refresh(first)
db.refresh(second)
assert first.queued is False
assert second.queued is True
async def test_a_stopped_reply_leaves_the_queue_alone(db, registered, make_chat, no_upstream):
"""Stop means stop. This is the decision the whole feature was shaped
around, and it must never be relaxed into "stop this one and start the
next"."""
_connection(db)
chat_id = make_chat()
reply = _mid_reply(db, chat_id)
chat = db.get(Chat, chat_id)
waiting = chat_service.create_message(db, chat, ROLE_USER, "next please", queued=True)
generation = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
generation.stopped = True
generation_service._drain(generation)
db.refresh(waiting)
assert waiting.queued is True
assert generation.drained is False
async def test_an_errored_reply_leaves_the_queue_alone(db, registered, make_chat, no_upstream):
"""The endpoint has just failed; sending the next prompt into it spends
somebody's words to produce a second failure."""
_connection(db)
chat_id = make_chat()
reply = _mid_reply(db, chat_id)
chat = db.get(Chat, chat_id)
waiting = chat_service.create_message(db, chat, ROLE_USER, "next please", queued=True)
generation = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
generation.error = "The endpoint refused."
generation_service._drain(generation)
db.refresh(waiting)
assert waiting.queued is True
async def test_a_superseded_generation_does_not_drain(db, registered, make_chat, no_upstream):
"""A regeneration cancels its predecessor, whose `finally:` still runs --
the same reason `_persist` refuses. Without this, regenerating would drain
the queue as a side effect."""
_connection(db)
chat_id = make_chat()
reply = _mid_reply(db, chat_id)
chat = db.get(Chat, chat_id)
waiting = chat_service.create_message(db, chat, ROLE_USER, "next please", queued=True)
abandoned = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
generation_service._RUNNING[reply.id] = generation_service.Generation(
chat_id=chat_id, message_id=reply.id
)
generation_service._drain(abandoned)
db.refresh(waiting)
assert waiting.queued is True
# --- Send now and Discard ----------------------------------------------------
def test_discarding_removes_the_row(client: TestClient, db, registered, make_chat):
_connection(db)
chat_id = make_chat()
chat = db.get(Chat, chat_id)
waiting = chat_service.create_message(db, chat, ROLE_USER, "never mind", queued=True)
waiting_id = waiting.id
response = client.post(f"/api/chats/{chat_id}/messages/{waiting_id}/discard")
assert response.status_code == 200
# Queried rather than `db.get`: the route committed in a session of its own,
# and this one still holds the instance.
db.expire_all()
assert db.scalar(select(Message).where(Message.id == waiting_id)) is None
def test_discarding_a_delivered_message_is_refused(client: TestClient, db, registered, make_chat):
"""Discard removes a row outright, so it must only ever reach one that was
never sent."""
_connection(db)
chat_id = make_chat()
chat = db.get(Chat, chat_id)
sent = chat_service.create_message(db, chat, ROLE_USER, "already gone")
assert client.post(f"/api/chats/{chat_id}/messages/{sent.id}/discard").status_code == 404
assert db.get(Message, sent.id) is not None
def test_send_now_delivers_and_starts_a_reply(
client: TestClient, db, registered, make_chat, no_upstream
):
_connection(db)
chat_id = make_chat()
chat = db.get(Chat, chat_id)
waiting = chat_service.create_message(db, chat, ROLE_USER, "go on then", queued=True)
response = client.post(f"/api/chats/{chat_id}/messages/{waiting.id}/send-now")
assert response.status_code == 200
db.refresh(waiting)
assert waiting.queued is False
assert len(no_upstream) == 1
def test_send_now_is_refused_while_a_reply_is_running(
client: TestClient, db, registered, make_chat, no_upstream
):
"""Jumping the queue is starting a second generation, which is the thing
this whole mechanism exists to stop."""
_connection(db)
chat_id = make_chat()
_mid_reply(db, chat_id)
chat = db.get(Chat, chat_id)
waiting = chat_service.create_message(db, chat, ROLE_USER, "me first", queued=True)
response = client.post(f"/api/chats/{chat_id}/messages/{waiting.id}/send-now")
assert response.status_code == 409
db.refresh(waiting)
assert waiting.queued is True
def test_neither_route_reaches_another_readers_chat(
client: TestClient, db, registered, make_chat
):
_connection(db)
chat_id = make_chat()
chat = db.get(Chat, chat_id)
waiting = chat_service.create_message(db, chat, ROLE_USER, "mine", queued=True)
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
assert client.post(f"/api/chats/{chat_id}/messages/{waiting.id}/discard").status_code == 404
assert client.post(f"/api/chats/{chat_id}/messages/{waiting.id}/send-now").status_code == 404
assert db.get(Message, waiting.id) is not None
# --- Everything else that touches the thread ---------------------------------
def test_editing_is_refused_while_a_reply_is_running(
client: TestClient, db, registered, make_chat, no_upstream
):
"""Editing rewinds and then starts a reply unconditionally. Pressing it
mid-stream was a second concurrent generation behind a pencil icon, and was
reachable before the queue existed too."""
_connection(db)
chat_id = make_chat()
_mid_reply(db, chat_id)
first = _messages(db, chat_id)[0]
response = client.post(
f"/api/chats/{chat_id}/messages/{first.id}/edit", data={"content": "rewritten"}
)
assert response.status_code == 409
db.refresh(first)
assert first.content == "what is lembas?"
assert len([m for m in _messages(db, chat_id) if not m.complete]) == 1
def test_compaction_does_not_summarise_a_waiting_prompt(db, registered, make_chat):
"""It would fold words no model has seen into the record, and then deliver
them again afterwards."""
from lembas.services import compaction as compaction_service
_connection(db)
chat_id = make_chat()
chat = db.get(Chat, chat_id)
chat_service.create_message(db, chat, ROLE_USER, "what is lembas?")
reply = chat_service.create_message(db, chat, ROLE_ASSISTANT, "Waybread.")
chat_service.create_message(db, chat, ROLE_USER, "still waiting", queued=True)
text = compaction_service.transcript(db, chat, upto=reply)
assert "still waiting" not in text