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:
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -205,6 +206,31 @@ async def test_files_are_written_and_read_back(db, user_id, machine, tmp_path):
|
||||
assert "note.txt" in listed.content
|
||||
|
||||
|
||||
async def test_writing_a_file_drops_the_project_listing(db, user_id, machine):
|
||||
"""Otherwise the model is shown a five-minute-old tree that it knows is
|
||||
wrong, and concludes the file it has just created does not exist.
|
||||
|
||||
The TTL is for drift nobody can see coming. This is not that: it is this
|
||||
process changing the tree it has just described.
|
||||
"""
|
||||
from lembas.services.agent import index as index_service
|
||||
|
||||
chat, profile = _setup(db, user_id, machine, mode=policy.MODE_AUTO)
|
||||
user = db.get(User, user_id)
|
||||
resolved = tools_service.resolve_tools(db, chat, user)
|
||||
context = tools_service.context_for(db, user, chat, tools=resolved)
|
||||
|
||||
index_service._CACHE[(profile.id, machine["dir"])] = index_service.ProjectIndex(
|
||||
paths=("stale.txt",), total=1, source="git", built_at=time.monotonic()
|
||||
)
|
||||
|
||||
await tools_service.run_tool(
|
||||
context, "file_write", '{"path": "fresh.txt", "content": "hi"}'
|
||||
)
|
||||
|
||||
assert index_service.cached(profile.id, machine["dir"]) is None
|
||||
|
||||
|
||||
# --- The runner backstop ----------------------------------------------------------
|
||||
async def test_a_runner_refuses_what_the_mode_forbids(db, user_id, machine):
|
||||
"""`_authorise` is the real gate and runs first. This is the belt to that
|
||||
@@ -486,6 +512,140 @@ async def test_an_agent_chat_is_told_its_real_round_budget(db, user_id, machine)
|
||||
assert values["max_rounds"] == "25"
|
||||
|
||||
|
||||
async def test_an_agent_chat_gets_the_rounds_it_was_promised(db, user_id, machine, monkeypatch):
|
||||
"""And is then allowed to use them, which is the half that was missing.
|
||||
|
||||
The budget sizes the loop and names itself in the out-of-rounds message, and
|
||||
the harness above tells the model the same number. But the comparison that
|
||||
ends the loop read the global `MAX_ROUNDS` of three. So an agent chat
|
||||
allowed forty rounds stopped after three and reported that it had taken
|
||||
forty: two wrong answers to "why did it stop", with no way to tell them
|
||||
apart from the outside.
|
||||
"""
|
||||
settings_store.update(db, {"max_steps": 5}, key=settings_store.AGENTS)
|
||||
chat, _profile = _setup(db, user_id, machine, mode=policy.MODE_AUTO)
|
||||
assistant = Message(chat_id=chat.id, role=ROLE_ASSISTANT, content="", complete=False)
|
||||
db.add(assistant)
|
||||
db.commit()
|
||||
|
||||
payloads: list[dict] = []
|
||||
|
||||
async def stream_chat(_endpoint, payload):
|
||||
payloads.append(payload)
|
||||
yield {
|
||||
"choices": [
|
||||
{"delta": {"tool_calls": [{"index": 0, "id": "c1", "function": {
|
||||
"name": "file_list", "arguments": '{"path": "."}'}}]}}
|
||||
]
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generation_service, "stream_chat", stream_chat)
|
||||
|
||||
async def _no_title(*_args, **_kwargs):
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr("lembas.services.chat.generate_title", _no_title)
|
||||
|
||||
generation = generation_service.Generation(chat_id=chat.id, message_id=assistant.id)
|
||||
await generation_service._run(generation)
|
||||
|
||||
# Five rounds that may call tools, then the one that gives up.
|
||||
assert len(payloads) == 6
|
||||
assert "after 5 rounds" in generation.tool_events[-1]["error"]
|
||||
|
||||
|
||||
# --- Interjecting while it works --------------------------------------------------
|
||||
async def test_a_queued_prompt_is_taken_in_between_rounds(db, user_id, machine, monkeypatch):
|
||||
"""The point of queueing in an agent chat: steering work already under way.
|
||||
|
||||
An agent that has just finished one loop and is about to start another is
|
||||
exactly when "actually, do it the other way" is worth having, and making it
|
||||
wait for the whole reply would mean it arrives after the thing it was meant
|
||||
to change.
|
||||
"""
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
chat, _profile = _setup(db, user_id, machine, mode=policy.MODE_AUTO)
|
||||
message_id = _pending_reply(db, chat)
|
||||
chat_service.create_message(
|
||||
db, chat, "user", "actually, check the other directory first", queued=True
|
||||
)
|
||||
|
||||
payloads: list[dict] = []
|
||||
monkeypatch.setattr(
|
||||
generation_service,
|
||||
"stream_chat",
|
||||
_stub_stream(
|
||||
[[_chunk("file_list", '{"path": "."}')], [_text("Done.")]],
|
||||
payloads,
|
||||
),
|
||||
)
|
||||
|
||||
generation = generation_service.Generation(chat_id=chat.id, message_id=message_id)
|
||||
await generation_service._run(generation)
|
||||
|
||||
# Verbatim, in the user role, with nothing wrapped around it: this genuinely
|
||||
# is the person at the keyboard, and quoting it would teach the model that a
|
||||
# user turn can be a quotation -- the distinction `execute_plan` relies on.
|
||||
assert payloads[1]["messages"][-1] == {
|
||||
"role": "user",
|
||||
"content": "actually, check the other directory first",
|
||||
}
|
||||
|
||||
|
||||
async def test_an_interjection_is_delivered_only_once(db, user_id, machine, monkeypatch):
|
||||
"""Marked delivered before the request goes out, so a crash loses it rather
|
||||
than asking the same thing twice and letting an agent act on it twice."""
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
chat, _profile = _setup(db, user_id, machine, mode=policy.MODE_AUTO)
|
||||
message_id = _pending_reply(db, chat)
|
||||
waiting = chat_service.create_message(db, chat, "user", "one more thing", queued=True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
generation_service,
|
||||
"stream_chat",
|
||||
_stub_stream(
|
||||
[
|
||||
[_chunk("file_list", '{"path": "."}')],
|
||||
[_chunk("file_list", '{"path": "src"}')],
|
||||
[_text("Done.")],
|
||||
],
|
||||
[],
|
||||
),
|
||||
)
|
||||
|
||||
generation = generation_service.Generation(chat_id=chat.id, message_id=message_id)
|
||||
await generation_service._run(generation)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(Message, waiting.id).queued is False
|
||||
assert generation.injected_ids == [waiting.id]
|
||||
|
||||
|
||||
async def test_the_reply_sorts_before_the_prompt_it_took_in(db, user_id, machine, monkeypatch):
|
||||
"""Otherwise the next request reads "answer, then the question it answered",
|
||||
and a small model dutifully answers it a second time."""
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
chat, _profile = _setup(db, user_id, machine, mode=policy.MODE_AUTO)
|
||||
message_id = _pending_reply(db, chat)
|
||||
waiting = chat_service.create_message(db, chat, "user", "and this", queued=True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
generation_service,
|
||||
"stream_chat",
|
||||
_stub_stream([[_chunk("file_list", '{"path": "."}')], [_text("Done.")]], []),
|
||||
)
|
||||
|
||||
generation = generation_service.Generation(chat_id=chat.id, message_id=message_id)
|
||||
await generation_service._run(generation)
|
||||
|
||||
db.expire_all()
|
||||
reply = db.get(Message, message_id)
|
||||
assert reply.created_at > db.get(Message, waiting.id).created_at
|
||||
|
||||
|
||||
# --- Plan mode's artifact ---------------------------------------------------------
|
||||
def test_plan_submit_is_offered_only_in_plan_mode(db, user_id, machine):
|
||||
"""It ends the reply. A model in Auto mode that proposed a plan instead of
|
||||
|
||||
Reference in New Issue
Block a user