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:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "lembas"
|
name = "lembas"
|
||||||
version = "0.6.1"
|
version = "0.6.2"
|
||||||
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
|
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
"""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 datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||||
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session as DBSession
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
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.
|
# Well under nginx's 60s default; see services/sse.py:KEEPALIVE.
|
||||||
KEEPALIVE_AFTER = 15.0
|
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:
|
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
|
||||||
chat = db.get(Chat, chat_id)
|
chat = db.get(Chat, chat_id)
|
||||||
@@ -70,6 +76,7 @@ def _new_chat(
|
|||||||
ssh_profile_id: str = "",
|
ssh_profile_id: str = "",
|
||||||
project_dir: str = "",
|
project_dir: str = "",
|
||||||
agent_mode: str = "",
|
agent_mode: str = "",
|
||||||
|
reasoning_effort: str = "",
|
||||||
) -> Chat:
|
) -> Chat:
|
||||||
"""Create a chat row, resolving which model it should use.
|
"""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
|
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
|
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
|
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
|
chosen = None
|
||||||
if model_id:
|
if model_id:
|
||||||
@@ -126,6 +134,12 @@ def _new_chat(
|
|||||||
# Left alone entirely on a plain chat, where it means nothing.
|
# Left alone entirely on a plain chat, where it means nothing.
|
||||||
if profile is not None and agent_mode.strip() in agent_policy.MODES:
|
if profile is not None and agent_mode.strip() in agent_policy.MODES:
|
||||||
chat.agent_mode = agent_mode.strip()
|
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.add(chat)
|
||||||
db.commit()
|
db.commit()
|
||||||
return chat
|
return chat
|
||||||
@@ -144,6 +158,7 @@ async def start_chat(
|
|||||||
ssh_profile_id: str = Form(""),
|
ssh_profile_id: str = Form(""),
|
||||||
project_dir: str = Form(""),
|
project_dir: str = Form(""),
|
||||||
agent_mode: str = Form(""),
|
agent_mode: str = Form(""),
|
||||||
|
reasoning_effort: str = Form(""),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Create a chat from its first message.
|
"""Create a chat from its first message.
|
||||||
|
|
||||||
@@ -166,6 +181,7 @@ async def start_chat(
|
|||||||
ssh_profile_id=ssh_profile_id,
|
ssh_profile_id=ssh_profile_id,
|
||||||
project_dir=project_dir,
|
project_dir=project_dir,
|
||||||
agent_mode=agent_mode,
|
agent_mode=agent_mode,
|
||||||
|
reasoning_effort=reasoning_effort,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
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")
|
@router.post("/{chat_id}/keep")
|
||||||
async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||||
"""Stop a temporary chat being temporary.
|
"""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)
|
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:
|
def _note_rewind(chat: Chat) -> None:
|
||||||
"""Record that an agent chat's transcript went back and the machine did not.
|
"""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
|
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
|
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.
|
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:
|
if file_ids:
|
||||||
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)
|
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)
|
||||||
db.refresh(user_message)
|
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(
|
assistant_message = chat_service.create_message(
|
||||||
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
|
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})
|
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", "")
|
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:
|
def _metrics_html(generation) -> str:
|
||||||
"""The metric chips for a reply still being written.
|
"""The metric chips for a reply still being written.
|
||||||
|
|
||||||
@@ -799,6 +1055,16 @@ async def edit_message(
|
|||||||
if not content and not message.attachments:
|
if not content and not message.attachments:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "A message cannot be empty.")
|
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
|
message.content = content
|
||||||
|
|
||||||
# Attachments cascade with their message, so the files go too.
|
# 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)
|
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}")
|
@router.post("/{chat_id}/interaction/{interaction_id}")
|
||||||
async def answer_interaction(
|
async def answer_interaction(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
+152
-1
@@ -16,14 +16,17 @@ from fastapi import (
|
|||||||
status,
|
status,
|
||||||
)
|
)
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
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.security import permissions
|
||||||
from lembas.services import files as files_service
|
from lembas.services import files as files_service
|
||||||
from lembas.services import settings_store
|
from lembas.services import settings_store
|
||||||
from lembas.services.fetch import FetchError, fetch
|
from lembas.services.fetch import FetchError, fetch
|
||||||
from lembas.services.library import documents as documents_service
|
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
|
from lembas.web.templating import templates
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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"))])
|
@router.get("/knowledge-picker", dependencies=[Depends(require_permission("files.upload"))])
|
||||||
async def knowledge_picker(
|
async def knowledge_picker(
|
||||||
request: Request, db: Db, user: RequiredUser, q: str = "", chat_id: str = ""
|
request: Request, db: Db, user: RequiredUser, q: str = "", chat_id: str = ""
|
||||||
@@ -210,9 +309,14 @@ async def mention_picker(
|
|||||||
][:20]
|
][:20]
|
||||||
|
|
||||||
documents: list = []
|
documents: list = []
|
||||||
|
notes: list = []
|
||||||
|
skills: list = []
|
||||||
|
bases: list = []
|
||||||
if permissions.has(db, user, "library.use"):
|
if permissions.has(db, user, "library.use"):
|
||||||
if needle:
|
if needle:
|
||||||
documents = documents_service.search(db, user, q, limit=10)
|
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:
|
else:
|
||||||
documents = list(
|
documents = list(
|
||||||
db.scalars(
|
db.scalars(
|
||||||
@@ -221,6 +325,48 @@ async def mention_picker(
|
|||||||
.limit(10)
|
.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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -230,6 +376,11 @@ async def mention_picker(
|
|||||||
"user": user,
|
"user": user,
|
||||||
"files": files,
|
"files": files,
|
||||||
"documents": documents,
|
"documents": documents,
|
||||||
|
"notes": notes,
|
||||||
|
"skills": skills,
|
||||||
|
"bases": bases,
|
||||||
|
"attachments": attachments,
|
||||||
|
"website": website,
|
||||||
"q": q,
|
"q": q,
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"profile_id": profile_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
|
# 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.
|
# did arrive is kept and is perfectly usable, it is just cut short.
|
||||||
stopped: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
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")
|
chat: Mapped[Chat] = relationship(back_populates="messages")
|
||||||
attachments: Mapped[list[Attachment]] = relationship( # noqa: F821
|
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."""
|
"""Walk the directory, by whichever means works first."""
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
try:
|
try:
|
||||||
|
found = None
|
||||||
for attempt in (_from_git, _from_find):
|
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:
|
if found is not None:
|
||||||
break
|
break
|
||||||
else:
|
|
||||||
found = None
|
|
||||||
if found is None:
|
if found is None:
|
||||||
found = await _from_sftp(executor, project_dir)
|
found = await _from_sftp(executor, project_dir)
|
||||||
except ExecError as exc:
|
except ExecError as exc:
|
||||||
@@ -491,6 +500,17 @@ def forget(profile_id: str) -> int:
|
|||||||
return len(doomed)
|
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:
|
def clear() -> None:
|
||||||
_CACHE.clear()
|
_CACHE.clear()
|
||||||
|
|
||||||
@@ -503,5 +523,6 @@ __all__ = [
|
|||||||
"clear",
|
"clear",
|
||||||
"ensure",
|
"ensure",
|
||||||
"forget",
|
"forget",
|
||||||
|
"forget_dir",
|
||||||
"render",
|
"render",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class AgentContext:
|
|||||||
chat_id: str
|
chat_id: str
|
||||||
label: str
|
label: str
|
||||||
project_dir: 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
|
mode: str = policy.MODE_MANUAL
|
||||||
allow: tuple[str, ...] = ()
|
allow: tuple[str, ...] = ()
|
||||||
deny: tuple[str, ...] = ()
|
deny: tuple[str, ...] = ()
|
||||||
@@ -112,6 +116,7 @@ def resolve(db: DBSession, chat: Chat, user: User | None) -> AgentContext | None
|
|||||||
chat_id=chat.id,
|
chat_id=chat.id,
|
||||||
label=profile.label,
|
label=profile.label,
|
||||||
project_dir=chat.project_dir or profile.default_dir or "",
|
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,
|
mode=chat.agent_mode if chat.agent_mode in policy.MODES else policy.MODE_MANUAL,
|
||||||
allow=tuple(values.get("allow_default") or ()),
|
allow=tuple(values.get("allow_default") or ()),
|
||||||
deny=tuple(values.get("deny_default") or ()),
|
deny=tuple(values.get("deny_default") or ()),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
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.base import ExecError, ExecRequest
|
||||||
from lembas.services.agent.session import AgentContext
|
from lembas.services.agent.session import AgentContext
|
||||||
from lembas.services.tools import (
|
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)
|
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(
|
return ToolOutcome(
|
||||||
f"Wrote {written} bytes to {path}.",
|
f"Wrote {written} bytes to {path}.",
|
||||||
_event("file_write", agent, path, status="ok", text=f"{written} bytes"),
|
_event("file_write", agent, path, status="ok", text=f"{written} bytes"),
|
||||||
|
|||||||
@@ -226,6 +226,11 @@ def build_messages(
|
|||||||
message
|
message
|
||||||
) <= compaction_service.moment(cutoff):
|
) <= compaction_service.moment(cutoff):
|
||||||
continue
|
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
|
# Skip turns that failed or produced nothing -- but a message carrying
|
||||||
# only an attachment has no text and must still be sent.
|
# only an attachment has no text and must still be sent.
|
||||||
if message.error:
|
if message.error:
|
||||||
@@ -447,6 +452,7 @@ def create_message(
|
|||||||
*,
|
*,
|
||||||
complete_: bool = True,
|
complete_: bool = True,
|
||||||
model_id: str = "",
|
model_id: str = "",
|
||||||
|
queued: bool = False,
|
||||||
) -> Message:
|
) -> Message:
|
||||||
message = Message(
|
message = Message(
|
||||||
chat_id=chat.id,
|
chat_id=chat.id,
|
||||||
@@ -454,6 +460,7 @@ def create_message(
|
|||||||
content=content,
|
content=content,
|
||||||
complete=complete_,
|
complete=complete_,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
|
queued=queued,
|
||||||
)
|
)
|
||||||
db.add(message)
|
db.add(message)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -98,8 +98,12 @@ def split(
|
|||||||
return [], list(messages)
|
return [], list(messages)
|
||||||
boundary = moment(cutoff)
|
boundary = moment(cutoff)
|
||||||
return (
|
return (
|
||||||
[m for m in messages if moment(m) <= boundary],
|
# A prompt still waiting to be sent stays on the live side whatever its
|
||||||
[m for m in messages if moment(m) > boundary],
|
# 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.chat_id == chat.id,
|
||||||
Message.created_at <= upto.created_at,
|
Message.created_at <= upto.created_at,
|
||||||
Message.error == "",
|
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:
|
if previous is not None:
|
||||||
query = query.where(Message.created_at > previous.created_at)
|
query = query.where(Message.created_at > previous.created_at)
|
||||||
|
|||||||
@@ -375,6 +375,43 @@ def store_text(
|
|||||||
return attachment
|
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(
|
def copy_document(
|
||||||
db: DBSession, *, user_id: str, chat_id: str | None, document
|
db: DBSession, *, user_id: str, chat_id: str | None, document
|
||||||
) -> Attachment:
|
) -> Attachment:
|
||||||
@@ -407,6 +444,12 @@ def copy_document(
|
|||||||
pages=document.pages,
|
pages=document.pages,
|
||||||
truncated=document.truncated,
|
truncated=document.truncated,
|
||||||
extraction_error=document.extraction_error,
|
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.add(attachment)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -129,6 +129,13 @@ class Generation:
|
|||||||
# the reply and is written onto the message, so the Execute button sends
|
# 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.
|
# exactly what was proposed rather than something parsed back out of prose.
|
||||||
plan: dict | None = None
|
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:
|
def touch(self) -> None:
|
||||||
self.version += 1
|
self.version += 1
|
||||||
@@ -187,6 +194,22 @@ def answer(
|
|||||||
return False
|
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)
|
_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)
|
model = chat_service.model_for(db, chat)
|
||||||
generation.context_limit = model.context_length if model is not None else 0
|
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)
|
generation.prompt_estimate = tokens.estimate_request(payload)
|
||||||
|
|
||||||
@@ -406,10 +435,17 @@ async def _run(generation: Generation) -> None:
|
|||||||
if generation.stopped or not calls:
|
if generation.stopped or not calls:
|
||||||
break
|
break
|
||||||
|
|
||||||
if round_number == tools_service.MAX_ROUNDS:
|
if round_number == budget:
|
||||||
# Out of rounds with the model still asking for tools. Recorded
|
# Out of rounds with the model still asking for tools. Recorded
|
||||||
# rather than silently dropped: an answer that stops here needs
|
# rather than silently dropped: an answer that stops here needs
|
||||||
# to be explicable.
|
# 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(
|
generation.tool_events.append(
|
||||||
{
|
{
|
||||||
"name": calls[0]["name"],
|
"name": calls[0]["name"],
|
||||||
@@ -454,6 +490,22 @@ async def _run(generation: Generation) -> None:
|
|||||||
generation.plan = outcome.event["plan"]
|
generation.plan = outcome.event["plan"]
|
||||||
generation.touch()
|
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}
|
payload = {**payload, "messages": messages}
|
||||||
|
|
||||||
# A plan ends the turn. One more request so the model can say what
|
# 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
|
# the row. The other order left a window in which the finished frame
|
||||||
# showed the previous turn's stored values.
|
# showed the previous turn's stored values.
|
||||||
_persist(generation, title, time.monotonic() - started)
|
_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.done = True
|
||||||
generation.finished_at = datetime.now(UTC)
|
generation.finished_at = datetime.now(UTC)
|
||||||
generation.touch()
|
generation.touch()
|
||||||
@@ -1006,6 +1063,117 @@ def _question_from(payload: dict) -> str:
|
|||||||
return ""
|
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:
|
def _persist(generation: Generation, title: str, elapsed: float) -> None:
|
||||||
"""Write the finished reply, name the chat, and set the unread flag.
|
"""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."
|
"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(
|
Fragment(
|
||||||
key="core.no_replay",
|
key="core.no_replay",
|
||||||
label="Results are not kept",
|
label="Results are not kept",
|
||||||
|
|||||||
@@ -576,6 +576,24 @@
|
|||||||
.msg:focus-within .msg__actions { opacity: 1; }
|
.msg:focus-within .msg__actions { opacity: 1; }
|
||||||
.msg__actions .is-copied { color: var(--success); }
|
.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 ---------------------------------------------------- */
|
/* --- Rendered Markdown ---------------------------------------------------- */
|
||||||
.msg__body > :first-child { margin-top: 0; }
|
.msg__body > :first-child { margin-top: 0; }
|
||||||
.msg__body > :last-child { margin-bottom: 0; }
|
.msg__body > :last-child { margin-bottom: 0; }
|
||||||
@@ -761,16 +779,32 @@
|
|||||||
.composer__mirror .tok-mention,
|
.composer__mirror .tok-mention,
|
||||||
.composer__mirror .tok-command {
|
.composer__mirror .tok-command {
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
/* Bled sideways so the rectangle does not sit hard against the next word,
|
/* Restated rather than inherited. `color: transparent` on the mirror is an
|
||||||
and the negative margin keeps the text metrics identical. */
|
inherited value, and a colour the span declares itself beats it -- which is
|
||||||
padding: 0 2px;
|
exactly what the transcript's rule below used to do from across the file,
|
||||||
margin: 0 -2px;
|
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. */
|
/* The same two in a sent message, where they are text rather than a backdrop --
|
||||||
.tok-mention {
|
and scoped to it, because unscoped they also matched the mirror's spans. */
|
||||||
|
.msg .tok-mention {
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
|
|||||||
@@ -75,7 +75,11 @@
|
|||||||
name: "effort",
|
name: "effort",
|
||||||
summary: "How hard a reasoning model should think",
|
summary: "How hard a reasoning model should think",
|
||||||
argument: "low | medium | high",
|
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); }
|
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",
|
name: "terminal",
|
||||||
summary: "Show or hide the terminal",
|
summary: "Show or hide the terminal",
|
||||||
@@ -163,6 +173,32 @@
|
|||||||
return function () { window.location = url; };
|
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 ---------------------------------------------------
|
/* --- Reasoning effort ---------------------------------------------------
|
||||||
The command drives the same select the composer shows, so there is one
|
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. */
|
piece of state and the control updates itself when the command is used. */
|
||||||
@@ -171,7 +207,11 @@
|
|||||||
function setEffort(rest) {
|
function setEffort(rest) {
|
||||||
var select = el("[data-effort]");
|
var select = el("[data-effort]");
|
||||||
if (!select) {
|
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();
|
var wanted = (rest || "").trim().toLowerCase();
|
||||||
if (!wanted) {
|
if (!wanted) {
|
||||||
|
|||||||
@@ -273,6 +273,24 @@
|
|||||||
} else if (option.dataset.mentionKnowledge) {
|
} else if (option.dataset.mentionKnowledge) {
|
||||||
body.append("document_id", option.dataset.mentionKnowledge);
|
body.append("document_id", option.dataset.mentionKnowledge);
|
||||||
attach("/api/files/from-knowledge", body);
|
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",
|
/* Whether this shell tells us where commands begin and end -- "live",
|
||||||
"loading" or "none". Everything the three buttons do keys off it. */
|
"loading" or "none". Everything the three buttons do keys off it. */
|
||||||
var integration = "loading";
|
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;
|
var lastCommand = null;
|
||||||
|
|
||||||
function say(text, isError) {
|
function say(text, isError) {
|
||||||
@@ -169,8 +173,12 @@
|
|||||||
and the buttons fetch what they need when they are pressed. */
|
and the buttons fetch what they need when they are pressed. */
|
||||||
if (integration !== "live") { integration = "live"; applyIntegration(); }
|
if (integration !== "live") { integration = "live"; applyIntegration(); }
|
||||||
showLast(payload.command);
|
showLast(payload.command);
|
||||||
if (autoSend) {
|
if (autoMode !== "off") {
|
||||||
capture(true).then(function (text) { intoComposer(text, true); });
|
capture(true).then(function (text) {
|
||||||
|
if (!text) return;
|
||||||
|
if (autoMode === "copy") return intoComposer(text, true);
|
||||||
|
sendStraightToChat(text);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -287,10 +295,10 @@
|
|||||||
var usable = integration === "live";
|
var usable = integration === "live";
|
||||||
auto.disabled = !usable;
|
auto.disabled = !usable;
|
||||||
auto.title = 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 " +
|
: "This shell did not load LLeMbas's command markers, so there is no way " +
|
||||||
"to tell where one command's output ends.";
|
"to tell where one command's output ends.";
|
||||||
if (!usable && autoSend) setAuto(false);
|
if (!usable && autoMode !== "off") setAuto("off");
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLast(command) {
|
function showLast(command) {
|
||||||
@@ -301,16 +309,32 @@
|
|||||||
slot.textContent = lastCommand ? lastCommand.summary : "";
|
slot.textContent = lastCommand ? lastCommand.summary : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAuto(on) {
|
var AUTO_SAID = {
|
||||||
autoSend = !!on;
|
off: "Commands are no longer attached automatically.",
|
||||||
var button = panel.querySelector("[data-terminal-auto]");
|
copy: "Every command you run will be put into the message box.",
|
||||||
if (button) {
|
send: "Every command you run will be sent as a message on its own."
|
||||||
button.setAttribute("aria-pressed", autoSend ? "true" : "false");
|
};
|
||||||
button.classList.toggle("is-active", autoSend);
|
|
||||||
}
|
function setAuto(mode) {
|
||||||
say(autoSend
|
autoMode = AUTO_SAID[mode] ? mode : "off";
|
||||||
? "Every command you run will be attached to your next message."
|
var select = panel && panel.querySelector("[data-terminal-auto]");
|
||||||
: "Commands are no longer attached automatically.");
|
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
|
/* A selection always wins, in every state. People rely on it, and it is the
|
||||||
@@ -427,10 +451,11 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return copyToClipboard();
|
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
|
/* 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.
|
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
|
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">
|
<div class="composer__context">
|
||||||
<select class="select select--sm" name="agent_mode" aria-label="Approval mode"
|
<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 %}
|
{% for value, label, hint in agent_modes %}
|
||||||
<option value="{{ value }}" title="{{ hint }}"
|
<option value="{{ value }}" title="{{ hint }}"
|
||||||
{{ 'selected' if value == chat.agent_mode }}>{{ label }}</option>
|
{{ 'selected' if value == chat.agent_mode }}>{{ label }}</option>
|
||||||
@@ -234,17 +240,27 @@
|
|||||||
Only on a model an administrator has marked as **reasoning**: that
|
Only on a model an administrator has marked as **reasoning**: that
|
||||||
flag has existed since the beginning with no reader at all, and
|
flag has existed since the beginning with no reader at all, and
|
||||||
offering the control everywhere would be offering a setting that does
|
offering the control everywhere would be offering a setting that does
|
||||||
nothing almost everywhere. Its own form, for the reason the mode has
|
nothing almost everywhere.
|
||||||
one -- a form cannot nest inside another.
|
|
||||||
|
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
|
<select class="select select--sm" name="reasoning_effort" data-effort
|
||||||
aria-label="Reasoning effort" title="How hard this model should think"
|
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>
|
<option value="">Effort: default</option>
|
||||||
{% for value in efforts %}
|
{% for value in efforts %}
|
||||||
<option value="{{ value }}"
|
<option value="{{ value }}" {{ 'selected' if chosen == value }}>
|
||||||
{{ 'selected' if chat.params_json.get('reasoning_effort') == value }}>
|
|
||||||
Effort: {{ value }}
|
Effort: {{ value }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -286,17 +302,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{# Outside the composer's form, and referenced by the mode select's `form`
|
{# Outside the composer's form, and referenced by the two selects' `form`
|
||||||
attribute above. hx-patch and not hx-post: there is no POST for a chat,
|
attributes above. These carry no htmx of their own: they exist so that
|
||||||
only PATCH, and htmx shows nothing when a request 405s -- which is how
|
`Nt(e)` -- htmx's "which form do the values come from", which reads
|
||||||
this control spent its whole life doing nothing. #}
|
`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" %}
|
{% if chat and chat.kind == "agent" %}
|
||||||
<form id="agent-mode-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
<form id="agent-mode-form"></form>
|
||||||
hx-trigger="change"></form>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if chat %}
|
{% if chat %}
|
||||||
<form id="chat-params-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
<form id="chat-params-form"></form>
|
||||||
hx-trigger="change"></form>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p class="composer__hint">
|
<p class="composer__hint">
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
all of it is escaped by autoescaping and none of it is marked safe.
|
all of it is escaped by autoescaping and none of it is marked safe.
|
||||||
#}
|
#}
|
||||||
<div id="mention-results">
|
<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)">
|
<p class="muted text-sm" style="padding: var(--sp-3)">
|
||||||
{% if q %}
|
{% if q %}
|
||||||
Nothing matches “{{ q }}”.
|
Nothing matches “{{ q }}”.
|
||||||
@@ -21,6 +22,22 @@
|
|||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% 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 %}
|
{% if files %}
|
||||||
<p class="picker__group">In the project</p>
|
<p class="picker__group">In the project</p>
|
||||||
<ul class="picker__list">
|
<ul class="picker__list">
|
||||||
@@ -61,5 +78,82 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,8 +13,16 @@
|
|||||||
escaped plain text for everyone else.
|
escaped plain text for everyone else.
|
||||||
#}
|
#}
|
||||||
{% set streaming = (message.role == "assistant" and not message.complete) %}
|
{% 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 %}
|
{% if streaming %}
|
||||||
hx-ext="sse"
|
hx-ext="sse"
|
||||||
sse-connect="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stream"
|
sse-connect="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stream"
|
||||||
@@ -210,7 +218,24 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
<footer class="msg__actions">
|
||||||
<button class="btn btn--icon btn--sm" type="button"
|
<button class="btn btn--icon btn--sm" type="button"
|
||||||
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
|
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
|
||||||
|
|||||||
@@ -50,12 +50,21 @@
|
|||||||
aria-label="Send to chat">
|
aria-label="Send to chat">
|
||||||
{{ icon("arrow-up", "icon--sm") }}
|
{{ icon("arrow-up", "icon--sm") }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--icon btn--sm" type="button" data-terminal-auto
|
{#
|
||||||
aria-pressed="false"
|
Three states, not two. A cycling icon button cannot say which of three it
|
||||||
title="Attach every command you run to your next message"
|
is in, and this one decides whether things are sent to a model without
|
||||||
aria-label="Send every command automatically">
|
being asked again -- so it says so in words.
|
||||||
{{ icon("sparkle", "icon--sm") }}
|
|
||||||
</button>
|
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"
|
<button class="btn btn--icon btn--sm" type="button" data-toggle="#terminal"
|
||||||
aria-label="Close terminal">
|
aria-label="Close terminal">
|
||||||
{{ icon("x", "icon--sm") }}
|
{{ icon("x", "icon--sm") }}
|
||||||
|
|||||||
@@ -209,6 +209,32 @@ def mock_http():
|
|||||||
httpx.AsyncClient = original
|
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
|
@pytest.fixture
|
||||||
def user_id(db: Session, registered: dict[str, str]) -> str:
|
def user_id(db: Session, registered: dict[str, str]) -> str:
|
||||||
"""The registered user's id.
|
"""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
|
# 404 and not 403: whether that connection exists is not this endpoint's to
|
||||||
# reveal to somebody who does not own it.
|
# reveal to somebody who does not own it.
|
||||||
assert client.get(f"/api/agents/{profile.id}/browse").status_code == 404
|
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
|
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():
|
def test_reading_the_cache_never_does_work():
|
||||||
"""`harness` calls this synchronously while assembling the system message,
|
"""`harness` calls this synchronously while assembling the system message,
|
||||||
so it must never be the thing that opens a connection."""
|
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 import settings_store
|
||||||
from lembas.services.agent import policy
|
from lembas.services.agent import policy
|
||||||
|
|
||||||
|
from .conftest import control_named
|
||||||
|
|
||||||
|
|
||||||
def _agent_chat(db, *, mode: str = policy.MODE_MANUAL) -> Chat:
|
def _agent_chat(db, *, mode: str = policy.MODE_MANUAL) -> Chat:
|
||||||
"""An agent chat pointed at a profile that is never actually connected to.
|
"""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
|
assert chat.agent_mode == policy.MODE_MANUAL
|
||||||
|
|
||||||
|
|
||||||
def test_the_rendered_form_patches(client: TestClient, db, registered):
|
def test_the_mode_select_carries_its_own_verb(client: TestClient, db, registered):
|
||||||
"""And that the template actually carries it, since that is where it broke.
|
"""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
|
This is the invariant, and the previous version of this test did not check
|
||||||
page: the model picker and the system-prompt box patch the same URL, so a
|
it. `hx-patch` lived on an empty sibling `<form>` that the select pointed at
|
||||||
looser assertion would have passed throughout the entire life of the bug.
|
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)
|
chat = _agent_chat(db)
|
||||||
body = client.get(f"/chat/{chat.id}").text
|
body = client.get(f"/chat/{chat.id}").text
|
||||||
|
|
||||||
assert 'id="agent-mode-form"' in body
|
select = control_named(body, "agent_mode")
|
||||||
form = body[body.index('id="agent-mode-form"') :][:200]
|
assert select["hx-patch"] == f"/api/chats/{chat.id}"
|
||||||
assert f'hx-patch="/api/chats/{chat.id}"' in form
|
assert "hx-post" not in select
|
||||||
assert "hx-post" not in form
|
# And the empty form still scopes the values, so the PATCH carries this
|
||||||
# And that the select outside it is actually submitted by it.
|
# field alone rather than the whole composer -- `project_dir` in a PATCH is
|
||||||
assert 'form="agent-mode-form"' in body
|
# a 409.
|
||||||
|
assert select["form"] == "agent-mode-form"
|
||||||
|
|
||||||
|
|
||||||
def test_the_mode_is_offered_beside_the_composer_not_in_the_topbar(
|
def test_the_mode_is_offered_beside_the_composer_not_in_the_topbar(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json as _json
|
import json as _json
|
||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
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
|
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 ----------------------------------------------------------
|
# --- The runner backstop ----------------------------------------------------------
|
||||||
async def test_a_runner_refuses_what_the_mode_forbids(db, user_id, machine):
|
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
|
"""`_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"
|
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 ---------------------------------------------------------
|
# --- Plan mode's artifact ---------------------------------------------------------
|
||||||
def test_plan_submit_is_offered_only_in_plan_mode(db, user_id, machine):
|
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
|
"""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.db.models import Chat, Connection, Model, User
|
||||||
from lembas.services import chat as chat_service
|
from lembas.services import chat as chat_service
|
||||||
|
|
||||||
|
from .conftest import control_named
|
||||||
|
|
||||||
|
|
||||||
def _model(db, **capabilities) -> Model:
|
def _model(db, **capabilities) -> Model:
|
||||||
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
|
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"})
|
client.post("/api/chats/start", data={"content": "hello", "model_id": "m"})
|
||||||
|
|
||||||
assert db.scalars(select(Chat)).one().params_json == {}
|
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
|
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 ---------------------------------------------------------------
|
# --- Attaching ---------------------------------------------------------------
|
||||||
def test_a_mentioned_file_arrives_with_its_contents(client: TestClient, db, registered, box):
|
def test_a_mentioned_file_arrives_with_its_contents(client: TestClient, db, registered, box):
|
||||||
profile = _profile(db, 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