Files
turnstone/tests/test_edit_file.py
T
Patrick Buckley c6f4c11870 feat: harness quick wins — batch edit_file, sandbox packages, stderr labels, JSON secret redaction, model resume (#217)
Five improvements from Opus self-evaluation of the turnstone harness:

1. Batch edit_file: edits array parameter for atomic multi-edit in a
   single tool call. Overlap detection, reverse-order application,
   mutual exclusivity with single-edit params.

2. Sandbox packages: new [sandbox] extras group with sympy, numpy,
   scipy, pytest — the sandbox already had graceful ImportError
   fallbacks, now the packages are actually installed.

3. Stderr labeling: bash tool output prefixes stderr lines with
   [stderr] so the model can distinguish errors from stdout.

4. JSON secret redaction: output guard now detects and redacts secrets
   in JSON format ("api_key": "...", "password": "...", etc.) with
   18 key patterns and 8-char minimum value length.

5. Model persisted on resume: workstream config now saves model and
   model_alias. Resume restores the original model via registry
   (same path as /model command), falling back to raw model name
   if the alias is no longer available.

24 new tests (23 in test_edit_file.py, 1 in test_sessions.py).
2026-03-29 05:21:08 -07:00

461 lines
15 KiB
Python

"""Tests for edit_file tool — single edit and batch edit modes."""
from __future__ import annotations
import os
from unittest.mock import MagicMock
import pytest
from turnstone.core.session import ChatSession
@pytest.fixture
def session(tmp_db, mock_openai_client):
"""Create a ChatSession wired to a temp database."""
return ChatSession(
client=mock_openai_client,
model="test-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
)
@pytest.fixture
def sample_file(tmp_path):
"""Create a sample file and return its path."""
p = tmp_path / "test.py"
p.write_text("line1\nline2\nline3\nline4\nline5\n")
return str(p)
def _mark_read(session: ChatSession, path: str) -> None:
"""Simulate a prior read_file so the edit guard passes."""
resolved = os.path.realpath(os.path.expanduser(path))
session._read_files.add(resolved)
# ── Single edit (backward compat) ────────────────────────────────────
class TestSingleEdit:
def test_basic_replace(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line2",
"new_string": "replaced",
},
)
assert result["needs_approval"]
assert result["func_name"] == "edit_file"
call_id, msg = session._exec_edit_file(result)
assert call_id == "c1"
assert "applied 1 edit" in msg
with open(sample_file) as f:
assert f.read() == "line1\nreplaced\nline3\nline4\nline5\n"
def test_missing_path(self, session):
result = session._prepare_edit_file(
"c1",
{
"old_string": "a",
"new_string": "b",
},
)
assert result.get("error")
assert "missing path" in result["error"]
def test_missing_old_string(self, session, sample_file):
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"new_string": "b",
},
)
assert result.get("error")
assert "old_string" in result["error"]
def test_identical_strings(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line1",
"new_string": "line1",
},
)
assert result.get("error")
assert "identical" in result["error"]
def test_old_string_not_found(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "nonexistent",
"new_string": "replaced",
},
)
assert result.get("error")
assert "not found" in result["error"]
def test_must_read_first(self, session, sample_file):
# Don't call _mark_read — should fail
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line1",
"new_string": "replaced",
},
)
assert result.get("error")
assert "must read_file" in result["error"]
def test_multiple_occurrences_without_near_line(self, session, tmp_path):
p = tmp_path / "dup.txt"
p.write_text("foo\nbar\nfoo\n")
path = str(p)
_mark_read(session, path)
result = session._prepare_edit_file(
"c1",
{
"path": path,
"old_string": "foo",
"new_string": "baz",
},
)
assert result.get("error")
assert "found 2 times" in result["error"]
def test_near_line_disambiguates(self, session, tmp_path):
p = tmp_path / "dup.txt"
p.write_text("foo\nbar\nfoo\n")
path = str(p)
_mark_read(session, path)
result = session._prepare_edit_file(
"c1",
{
"path": path,
"old_string": "foo",
"new_string": "baz",
"near_line": 3,
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
assert "applied 1 edit" in msg
with open(path) as f:
assert f.read() == "foo\nbar\nbaz\n"
def test_deletion(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line3\n",
"new_string": "",
},
)
assert result["needs_approval"]
assert "deletion" in result["preview"]
call_id, msg = session._exec_edit_file(result)
with open(sample_file) as f:
assert f.read() == "line1\nline2\nline4\nline5\n"
# ── Batch edits ──────────────────────────────────────────────────────
class TestBatchEdit:
def test_two_edits_applied_atomically(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "first"},
{"old_string": "line5", "new_string": "last"},
],
},
)
assert result["needs_approval"]
assert "2 edits" in result["header"]
call_id, msg = session._exec_edit_file(result)
assert "applied 2 edits" in msg
with open(sample_file) as f:
assert f.read() == "first\nline2\nline3\nline4\nlast\n"
def test_three_edits_middle_of_file(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line2", "new_string": "second"},
{"old_string": "line3", "new_string": "third"},
{"old_string": "line4", "new_string": "fourth"},
],
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
assert "applied 3 edits" in msg
with open(sample_file) as f:
assert f.read() == "line1\nsecond\nthird\nfourth\nline5\n"
def test_overlapping_edits_rejected(self, session, tmp_path):
p = tmp_path / "overlap.txt"
p.write_text("abcdefgh\n")
path = str(p)
_mark_read(session, path)
result = session._prepare_edit_file(
"c1",
{
"path": path,
"edits": [
{"old_string": "abcdef", "new_string": "XXX"},
{"old_string": "defgh", "new_string": "YYY"},
],
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
assert "overlap" in msg.lower()
# File should be untouched
with open(path) as f:
assert f.read() == "abcdefgh\n"
def test_batch_edit_not_found(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "first"},
{"old_string": "nonexistent", "new_string": "oops"},
],
},
)
assert result.get("error")
assert "edits[1]" in result["error"]
assert "not found" in result["error"]
def test_batch_edit_missing_old_string(self, session, sample_file):
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "first"},
{"new_string": "oops"},
],
},
)
assert result.get("error")
assert "edits[1]" in result["error"]
assert "old_string" in result["error"]
def test_batch_edit_identical_strings(self, session, sample_file):
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "line1"},
],
},
)
assert result.get("error")
assert "identical" in result["error"]
def test_batch_with_near_line(self, session, tmp_path):
p = tmp_path / "dup.txt"
p.write_text("foo\nbar\nfoo\nbaz\n")
path = str(p)
_mark_read(session, path)
result = session._prepare_edit_file(
"c1",
{
"path": path,
"edits": [
{"old_string": "foo", "new_string": "first_foo", "near_line": 1},
{"old_string": "foo", "new_string": "second_foo", "near_line": 3},
],
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
assert "applied 2 edits" in msg
with open(path) as f:
assert f.read() == "first_foo\nbar\nsecond_foo\nbaz\n"
def test_batch_with_deletion(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line2\n", "new_string": ""},
{"old_string": "line4\n", "new_string": ""},
],
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
with open(sample_file) as f:
assert f.read() == "line1\nline3\nline5\n"
def test_single_item_edits_array(self, session, sample_file):
"""An edits array with one item should work like a single edit."""
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line3", "new_string": "middle"},
],
},
)
assert result["needs_approval"]
# Single edit — no "(N edits)" count in header
assert "edits)" not in result["header"]
call_id, msg = session._exec_edit_file(result)
assert "applied 1 edit" in msg
with open(sample_file) as f:
assert f.read() == "line1\nline2\nmiddle\nline4\nline5\n"
# ── Mutual exclusivity ──────────────────────────────────────────────
class TestMutualExclusivity:
def test_both_single_and_batch_rejected(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line1",
"new_string": "replaced",
"edits": [
{"old_string": "line2", "new_string": "also_replaced"},
],
},
)
assert result.get("error")
assert "not both" in result["error"]
def test_neither_single_nor_batch(self, session, sample_file):
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
},
)
assert result.get("error")
assert "old_string" in result["error"]
def test_empty_edits_array_falls_through_to_single(self, session, sample_file):
"""An empty edits array should be treated as no batch."""
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [],
},
)
# Falls through to single-edit path, which requires old_string
assert result.get("error")
assert "old_string" in result["error"]
# ── TOCTOU edge cases ───────────────────────────────────────────────
class TestExecEdgeCases:
def test_file_changed_between_prepare_and_exec(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line2",
"new_string": "replaced",
},
)
assert result["needs_approval"]
# Modify the file after prepare
with open(sample_file, "w") as f:
f.write("completely different content\n")
call_id, msg = session._exec_edit_file(result)
assert "no longer found" in msg
def test_file_deleted_between_prepare_and_exec(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line2",
"new_string": "replaced",
},
)
assert result["needs_approval"]
os.unlink(sample_file)
call_id, msg = session._exec_edit_file(result)
assert "Error" in msg
def test_batch_file_changed_partial_match(self, session, sample_file):
"""If file changes so one edit fails, none should be applied."""
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "first"},
{"old_string": "line5", "new_string": "last"},
],
},
)
assert result["needs_approval"]
# Remove line5 between prepare and exec
with open(sample_file, "w") as f:
f.write("line1\nline2\nline3\nline4\n")
call_id, msg = session._exec_edit_file(result)
assert "no longer found" in msg
# line1 should NOT have been edited (atomic failure)
with open(sample_file) as f:
assert "line1" in f.read()