"""Reading and writing a file for somebody who is about to edit it. The model-facing `read_file`/`write_file` pair is deliberately untouched: what they return is a contract a model has been shown, and it is the right contract for a model. It is the wrong one here, and these are the cases that say why. """ from __future__ import annotations import pytest from lembas.services.agent import ssh as ssh_service from lembas.services.agent.base import Conflict, ExecError # Stands up something real -- see the `slow` marker in pyproject.toml. pytestmark = pytest.mark.slow asyncssh = pytest.importorskip("asyncssh") class _Server(asyncssh.SSHServer): def begin_auth(self, username: str) -> bool: return False @pytest.fixture async def machine(tmp_path): project = tmp_path / "project" project.mkdir() server = await asyncssh.create_server( _Server, "127.0.0.1", 0, server_host_keys=[asyncssh.generate_private_key("ssh-ed25519")], sftp_factory=True, ) port = next(iter(server.sockets)).getsockname()[1] line, fingerprint = await ssh_service.capture_host_key("127.0.0.1", port) try: yield {"port": port, "host_key": line, "dir": str(project), "path": project} finally: server.close() await server.wait_closed() def _executor(machine) -> ssh_service.SshExecutor: return ssh_service.SshExecutor( { "host": "127.0.0.1", "port": machine["port"], "username": "tester", "auth": "password", "credential": "", "host_key": machine["host_key"], }, machine["dir"], ) # --- Fidelity ------------------------------------------------------------------ async def test_an_escape_sequence_survives_a_round_trip(machine): """The whole reason this is not `read_file`. That one ends in `clean_output`, which strips ANSI escapes -- right for the output of a command, and here it means opening a file and pressing Save rewrites it with the escapes gone.""" original = "red \x1b[31mtext\x1b[0m here\n" (machine["path"] / "colours.txt").write_text(original) executor = _executor(machine) opened = await executor.read_text("colours.txt") assert opened.text == original await executor.write_text("colours.txt", opened.text, if_unchanged=opened.revision) assert (machine["path"] / "colours.txt").read_text() == original async def test_the_model_facing_read_still_strips_them(machine): """Pinned as a pair: the contract a model was shown has not moved.""" (machine["path"] / "colours.txt").write_text("red \x1b[31mtext\x1b[0m here\n") text = await _executor(machine).read_file("colours.txt") assert "\x1b[31m" not in text async def test_undecodable_bytes_are_reported_rather_than_replaced(machine): """errors="replace" would hand back U+FFFD for every one of them, and saving that back is how a file is quietly destroyed.""" (machine["path"] / "blob.bin").write_bytes(b"\xff\xfe\x00\x01binary") opened = await _executor(machine).read_text("blob.bin") assert opened.binary is True assert opened.text == "" async def test_a_nul_byte_early_on_reads_as_binary(machine): (machine["path"] / "blob.bin").write_bytes(b"text\x00more text") assert (await _executor(machine).read_text("blob.bin")).binary is True async def test_utf8_beyond_ascii_is_not_binary(machine): (machine["path"] / "note.txt").write_text("a mallorn tree — Lothlórien\n") opened = await _executor(machine).read_text("note.txt") assert opened.binary is False assert "Lothlórien" in opened.text # --- Size ------------------------------------------------------------------------ async def test_a_large_file_opens_truncated(machine): (machine["path"] / "big.log").write_text("x" * (ssh_service.MAX_READ_BYTES + 500)) opened = await _executor(machine).read_text("big.log") assert opened.truncated is True assert len(opened.text) == ssh_service.MAX_READ_BYTES async def test_an_oversize_write_is_refused_not_truncated(machine): """`write_file` truncates because a model is told how many bytes it wrote. Somebody pressing Save would lose the tail with nothing said.""" executor = _executor(machine) (machine["path"] / "big.txt").write_text("small") with pytest.raises(ExecError, match="Nothing was written"): await executor.write_text("big.txt", "y" * (ssh_service.MAX_WRITE_BYTES + 1)) assert (machine["path"] / "big.txt").read_text() == "small" # --- Conflict --------------------------------------------------------------------- async def test_a_file_that_moved_underneath_refuses_the_save(machine): import os target = machine["path"] / "note.txt" target.write_text("alpha\n") executor = _executor(machine) opened = await executor.read_text("note.txt") # Somebody else's editor, a build, a checkout. The size differs, so this # does not depend on the filesystem's mtime resolution. target.write_text("something else entirely\n") os.utime(target, (0, 0)) with pytest.raises(Conflict): await executor.write_text("note.txt", "beta\n", if_unchanged=opened.revision) assert target.read_text() == "something else entirely\n" async def test_a_save_with_no_token_overwrites(machine): """Which is what Overwrite on the conflict card does.""" target = machine["path"] / "note.txt" target.write_text("alpha\n") await _executor(machine).write_text("note.txt", "beta\n") assert target.read_text() == "beta\n" async def test_a_new_file_can_be_created(machine): """Open a path that is not there, type, Save. The stat finds nothing and there is nothing for the token to disagree with.""" executor = _executor(machine) await executor.write_text("fresh.txt", "hello\n", if_unchanged="0:0") assert (machine["path"] / "fresh.txt").read_text() == "hello\n" async def test_the_revision_moves_after_a_write(machine): """Or the second save from the same tab would always conflict.""" target = machine["path"] / "note.txt" target.write_text("alpha\n") executor = _executor(machine) opened = await executor.read_text("note.txt") written = await executor.write_text( "note.txt", "much longer contents\n", if_unchanged=opened.revision ) assert written.revision != opened.revision await executor.write_text("note.txt", "again\n", if_unchanged=written.revision) assert target.read_text() == "again\n" async def test_reading_something_that_is_not_there_says_so(machine): with pytest.raises(ExecError, match="no file"): await _executor(machine).read_text("nowhere.txt")