52770d7ab1
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>
290 lines
10 KiB
Python
290 lines
10 KiB
Python
"""Listing a project directory: the ladder, the cache, and the budget.
|
|
|
|
The ladder is exercised against a fake executor rather than a real host,
|
|
because what is being tested is *which rung is chosen* and what happens when
|
|
one falls through -- and a real box either has git or does not, which would
|
|
make the interesting cases unreachable.
|
|
|
|
`scan_dir` itself is tested against a real SFTP server in test_agent_browse.py.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from lembas.services.agent import index as index_service
|
|
from lembas.services.agent.base import ExecError, ExecResult, RemoteEntry
|
|
|
|
|
|
class _Fake:
|
|
"""An executor that answers whatever the test says, and records the asking."""
|
|
|
|
def __init__(self, *, answers: dict[str, ExecResult] | None = None, tree=None):
|
|
self.answers = answers or {}
|
|
self.tree = tree or {}
|
|
self.commands: list[str] = []
|
|
self.scans: list[str] = []
|
|
|
|
async def run(self, request) -> ExecResult:
|
|
self.commands.append(request.command)
|
|
for fragment, result in self.answers.items():
|
|
if fragment in request.command:
|
|
return result
|
|
return ExecResult(exit_status=1, output="")
|
|
|
|
async def scan_dir(self, path: str) -> list[RemoteEntry]:
|
|
self.scans.append(path)
|
|
if path not in self.tree:
|
|
raise ExecError(f"There is no directory at {path}.")
|
|
return self.tree[path]
|
|
|
|
|
|
def _ok(output: str) -> ExecResult:
|
|
return ExecResult(exit_status=0, output=output)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_cache():
|
|
index_service.clear()
|
|
yield
|
|
index_service.clear()
|
|
|
|
|
|
# --- The ladder --------------------------------------------------------------
|
|
async def test_git_is_tried_first_and_wins_where_it_works():
|
|
"""`--exclude-standard` is the whole reason. A repository already carries
|
|
somebody's considered list of what is not part of the project, and
|
|
reproducing it by hand is how an index ends up mostly build output."""
|
|
executor = _Fake(answers={"git ls-files": _ok("README.md\nsrc/main.py\n")})
|
|
|
|
found = await index_service.build(executor, "/work")
|
|
|
|
assert found.source == "git"
|
|
assert found.paths == ("README.md", "src/main.py")
|
|
assert executor.commands == [executor.commands[0]]
|
|
assert "--exclude-standard" in executor.commands[0]
|
|
|
|
|
|
async def test_find_takes_over_when_the_directory_is_not_a_repository():
|
|
executor = _Fake(answers={"find .": _ok("./README.md\n./src/main.py\n")})
|
|
|
|
found = await index_service.build(executor, "/work")
|
|
|
|
assert found.source == "find"
|
|
assert found.paths == ("README.md", "src/main.py")
|
|
assert len(executor.commands) == 2 # git was tried, and fell through
|
|
|
|
|
|
async def test_find_prunes_the_usual_noise():
|
|
"""Not applied to the git rung, which has already applied the repository's
|
|
own rules -- a checked-in `vendor/` is checked in on purpose."""
|
|
executor = _Fake(answers={"find .": _ok("./a\n")})
|
|
|
|
await index_service.build(executor, "/work")
|
|
|
|
assert "node_modules" in executor.commands[1]
|
|
assert "'.git'" in executor.commands[1]
|
|
|
|
|
|
async def test_sftp_is_the_last_resort_and_always_works():
|
|
executor = _Fake(
|
|
tree={
|
|
"/work": [RemoteEntry("src", True), RemoteEntry("README.md", False)],
|
|
"src": [RemoteEntry("main.py", False)],
|
|
}
|
|
)
|
|
|
|
found = await index_service.build(executor, "/work")
|
|
|
|
assert found.source == "sftp"
|
|
assert "README.md" in found.paths
|
|
assert "src/main.py" in found.paths
|
|
|
|
|
|
async def test_a_directory_that_cannot_be_read_at_all_is_empty_not_an_error():
|
|
"""A reply must not fail because a listing did. The fragment carrying it
|
|
disappears instead, which is what `requires` is for."""
|
|
executor = _Fake(tree={})
|
|
|
|
found = await index_service.build(executor, "/work")
|
|
|
|
assert found.paths == ()
|
|
assert not found.ok
|
|
|
|
|
|
async def test_control_characters_are_stripped_from_a_filename():
|
|
"""These end up inside a system prompt. An escape sequence in a filename
|
|
could otherwise repaint the transcript it is quoted in."""
|
|
executor = _Fake(answers={"git ls-files": _ok("ok.py\nevil\x1b[31m.py\n")})
|
|
|
|
found = await index_service.build(executor, "/work")
|
|
|
|
assert "evil[31m.py" in found.paths
|
|
assert "\x1b" not in "".join(found.paths)
|
|
|
|
|
|
async def test_the_number_of_paths_is_capped_and_says_so():
|
|
""""There is nothing else here" and "I stopped looking" are different
|
|
answers, and it must not give the first for the second."""
|
|
many = "\n".join(f"f{i}.py" for i in range(index_service.MAX_ENTRIES + 50))
|
|
executor = _Fake(answers={"git ls-files": _ok(many)})
|
|
|
|
found = await index_service.build(executor, "/work")
|
|
|
|
assert len(found.paths) == index_service.MAX_ENTRIES
|
|
assert found.truncated
|
|
|
|
|
|
# --- The cache ---------------------------------------------------------------
|
|
async def test_a_second_ask_does_not_walk_again():
|
|
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", "/work")
|
|
|
|
assert len(executor.commands) == 1
|
|
|
|
|
|
async def test_refreshing_walks_again():
|
|
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", "/work", refresh=True)
|
|
|
|
assert len(executor.commands) == 2
|
|
|
|
|
|
async def test_a_stale_index_is_not_returned():
|
|
executor = _Fake(answers={"git ls-files": _ok("a.py\n")})
|
|
await index_service.ensure(executor, "profile-1", "/work")
|
|
|
|
index_service._CACHE[("profile-1", "/work")] = index_service.ProjectIndex(
|
|
paths=("a.py",), total=1, source="git", built_at=time.monotonic() - index_service.TTL - 1
|
|
)
|
|
|
|
assert index_service.cached("profile-1", "/work") is None
|
|
|
|
|
|
async def test_forgetting_a_connection_drops_its_listings():
|
|
"""A connection somebody has just revoked must not leave a listing of the
|
|
machine behind it. Called from the same three places that close its
|
|
terminals."""
|
|
executor = _Fake(answers={"git ls-files": _ok("a.py\n")})
|
|
await index_service.ensure(executor, "profile-1", "/work")
|
|
await index_service.ensure(executor, "profile-2", "/work")
|
|
|
|
assert index_service.forget("profile-1") == 1
|
|
assert index_service.cached("profile-1", "/work") is 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():
|
|
"""`harness` calls this synchronously while assembling the system message,
|
|
so it must never be the thing that opens a connection."""
|
|
assert index_service.cached("nobody", "/nowhere") is None
|
|
|
|
|
|
# --- The budget --------------------------------------------------------------
|
|
def _index(paths) -> index_service.ProjectIndex:
|
|
return index_service.ProjectIndex(
|
|
paths=tuple(sorted(paths)), total=len(paths), source="git", built_at=time.monotonic()
|
|
)
|
|
|
|
|
|
def test_a_small_tree_is_shown_whole():
|
|
text = index_service.render(_index(["README.md", "src/main.py"]), 2000)
|
|
|
|
assert "README.md" in text
|
|
assert "main.py" in text
|
|
assert "files)" not in text
|
|
|
|
|
|
def test_a_big_directory_becomes_a_count():
|
|
paths = ["README.md"] + [f"vendor/f{i}.js" for i in range(400)]
|
|
|
|
text = index_service.render(_index(paths), 300)
|
|
|
|
assert "vendor/ (400 files)" in text
|
|
assert "README.md" in text
|
|
assert "file_list" in text
|
|
|
|
|
|
def test_the_deepest_big_directory_is_collapsed_first():
|
|
"""Collapsing by size alone takes `src/` before `src/.../vendor/` -- it is
|
|
bigger because it *contains* it -- and loses every name worth having in
|
|
order to fold away one directory of third-party files."""
|
|
paths = [f"src/api/{n}.py" for n in "abcde"] + [
|
|
f"src/web/vendor/f{i}.js" for i in range(300)
|
|
]
|
|
|
|
text = index_service.render(_index(paths), 400)
|
|
|
|
assert "vendor/ (300 files)" in text
|
|
assert "a.py" in text # src/api survived
|
|
|
|
|
|
def test_a_flat_directory_of_thousands_is_still_bounded():
|
|
"""The one shape collapsing cannot help with: no directory to fold them
|
|
into, so the running budget has to bite instead."""
|
|
text = index_service.render(_index([f"dump{i:05d}.log" for i in range(5000)]), 400)
|
|
|
|
assert len(text) < 1200
|
|
assert "more files" in text
|
|
|
|
|
|
def test_a_budget_of_zero_renders_nothing():
|
|
"""Which is how "index it for the picker, but say nothing to the model" is
|
|
expressed. The fragment vanishes rather than appearing empty."""
|
|
assert index_service.render(_index(["a.py"]), 0) == ""
|
|
|
|
|
|
def test_an_empty_index_renders_nothing():
|
|
assert index_service.render(index_service.ProjectIndex(), 2000) == ""
|
|
|
|
|
|
def test_a_truncated_index_says_it_is_a_sample():
|
|
sample = index_service.ProjectIndex(
|
|
paths=("a.py", "b.py"), total=9000, truncated=True, source="find", built_at=time.monotonic()
|
|
)
|
|
|
|
assert "sample" in index_service.render(sample, 2000)
|