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:
@@ -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
@@ -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,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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
found = await attempt(executor, project_dir)
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -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 ()),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
say(autoSend
|
||||
? "Every command you run will be attached to your next message."
|
||||
: "Commands are no longer attached automatically.");
|
||||
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]);
|
||||
}
|
||||
|
||||
/* 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user