main
py 652 lines 19.7 KB
Raw
1 import asyncio
2 import importlib
3 import inspect
4 import os
5 import sys
6 import types
7 from dataclasses import dataclass
8 from pathlib import Path
9
10 import pytest
11
12 PROJECT_ROOT = Path(__file__).resolve().parents[1]
13 if str(PROJECT_ROOT) not in sys.path:
14 sys.path.insert(0, str(PROJECT_ROOT))
15
16 from plugins._text_editor.helpers.context_patch import ContextPatchError
17 from plugins._text_editor.helpers.file_ops import (
18 apply_context_patch_file,
19 apply_exact_replace_file,
20 )
21 from plugins._text_editor.helpers.patch_request import (
22 exact_replace_to_patch_text,
23 parse_patch_request,
24 )
25 from plugins._text_editor.helpers.patch_state import (
26 LOCAL_FRESHNESS_KEY,
27 REMOTE_FRESHNESS_KEY,
28 apply_patch_post_state,
29 check_patch_freshness,
30 mark_file_state_stale,
31 record_file_state,
32 )
33
34
35 def test_context_patch_chains_after_line_shift(tmp_path: Path) -> None:
36 target = tmp_path / "sample.txt"
37 target.write_text("alpha\nbeta\ngamma\n", encoding="utf-8")
38
39 first = apply_context_patch_file(
40 str(target),
41 (
42 "*** Begin Patch\n"
43 "*** Update File: sample.txt\n"
44 "@@ alpha\n"
45 "+inserted\n"
46 "*** End Patch"
47 ),
48 )
49 second = apply_context_patch_file(
50 str(target),
51 (
52 "*** Begin Patch\n"
53 "*** Update File: sample.txt\n"
54 " beta\n"
55 "-gamma\n"
56 "+gamma-updated\n"
57 "*** End Patch"
58 ),
59 )
60
61 assert first["total_lines"] == 4
62 assert first["hunk_count"] == 1
63 assert second["total_lines"] == 4
64 assert target.read_text(encoding="utf-8") == (
65 "alpha\ninserted\nbeta\ngamma-updated\n"
66 )
67
68
69 def test_context_patch_inserts_after_anchor(tmp_path: Path) -> None:
70 target = tmp_path / "sample.txt"
71 target.write_text("alpha\nbeta\n", encoding="utf-8")
72
73 result = apply_context_patch_file(
74 str(target),
75 (
76 "*** Begin Patch\n"
77 "*** Update File: sample.txt\n"
78 "@@ alpha\n"
79 "+inserted\n"
80 "*** End Patch"
81 ),
82 )
83
84 assert result["line_from"] == 2
85 assert result["line_to"] == 2
86 assert target.read_text(encoding="utf-8") == "alpha\ninserted\nbeta\n"
87
88
89 def test_context_patch_replaces_matching_context(tmp_path: Path) -> None:
90 target = tmp_path / "sample.txt"
91 target.write_text("alpha\nbeta\ngamma\n", encoding="utf-8")
92
93 result = apply_context_patch_file(
94 str(target),
95 (
96 "*** Begin Patch\n"
97 "*** Update File: sample.txt\n"
98 " beta\n"
99 "-gamma\n"
100 "+delta\n"
101 "*** End Patch"
102 ),
103 )
104
105 assert result["line_from"] == 2
106 assert target.read_text(encoding="utf-8") == "alpha\nbeta\ndelta\n"
107
108
109 def test_exact_replace_file_replaces_one_span(tmp_path: Path) -> None:
110 target = tmp_path / "sample.txt"
111 target.write_text("alpha\nstatus = draft\ngamma\n", encoding="utf-8")
112
113 result = apply_exact_replace_file(
114 str(target), "status = draft", "status = ready"
115 )
116
117 assert result["replacement_count"] == 1
118 assert result["line_from"] == 2
119 assert target.read_text(encoding="utf-8") == "alpha\nstatus = ready\ngamma\n"
120
121
122 def test_exact_replace_file_rejects_ambiguous_match(tmp_path: Path) -> None:
123 target = tmp_path / "sample.txt"
124 target.write_text("alpha\nalpha\n", encoding="utf-8")
125
126 with pytest.raises(ValueError, match="matched 2 times"):
127 apply_exact_replace_file(str(target), "alpha", "beta")
128
129 assert target.read_text(encoding="utf-8") == "alpha\nalpha\n"
130
131
132 def test_exact_replace_to_patch_text_works_with_context_patch(tmp_path: Path) -> None:
133 target = tmp_path / "sample.txt"
134 target.write_text("alpha\nstatus = draft\ngamma\n", encoding="utf-8")
135
136 patch_text = exact_replace_to_patch_text(
137 "sample.txt", "status = draft", "status = ready"
138 )
139 apply_context_patch_file(str(target), patch_text)
140
141 assert target.read_text(encoding="utf-8") == "alpha\nstatus = ready\ngamma\n"
142
143
144 def test_context_patch_replaces_when_anchor_is_target_line(
145 tmp_path: Path,
146 ) -> None:
147 target = tmp_path / "sample.py"
148 target.write_text(
149 (
150 "def main():\n"
151 " print(greet(\"Agent Zero\"))\n"
152 "\n"
153 "\n"
154 "if __name__ == \"__main__\":\n"
155 " main()\n"
156 ),
157 encoding="utf-8",
158 )
159
160 result = apply_context_patch_file(
161 str(target),
162 (
163 "*** Begin Patch\n"
164 "*** Update File: sample.py\n"
165 "@@ print(greet(\"Agent Zero\"))\n"
166 "- print(greet(\"Agent Zero\"))\n"
167 "+ print(greet(\"Agent Zero\").upper())\n"
168 "*** End Patch"
169 ),
170 )
171
172 assert result["line_from"] == 2
173 assert target.read_text(encoding="utf-8") == (
174 "def main():\n"
175 " print(greet(\"Agent Zero\").upper())\n"
176 "\n"
177 "\n"
178 "if __name__ == \"__main__\":\n"
179 " main()\n"
180 )
181
182
183 def test_context_patch_rejects_ambiguous_unanchored_context(
184 tmp_path: Path,
185 ) -> None:
186 target = tmp_path / "sample.txt"
187 target.write_text("same\nold\nsame\nold\n", encoding="utf-8")
188
189 with pytest.raises(ContextPatchError, match="matched multiple locations"):
190 apply_context_patch_file(
191 str(target),
192 (
193 "*** Begin Patch\n"
194 "*** Update File: sample.txt\n"
195 " same\n"
196 "-old\n"
197 "+new\n"
198 "*** End Patch"
199 ),
200 )
201
202
203 @pytest.mark.parametrize(
204 "patch_text, expected",
205 [
206 (
207 "*** Begin Patch\n*** Add File: sample.txt\n+x\n*** End Patch",
208 "supports update hunks only",
209 ),
210 (
211 "*** Begin Patch\n*** Delete File: sample.txt\n*** End Patch",
212 "supports update hunks only",
213 ),
214 (
215 (
216 "*** Begin Patch\n"
217 "*** Update File: sample.txt\n"
218 "*** Move to: other.txt\n"
219 "*** End Patch"
220 ),
221 "does not support file moves",
222 ),
223 (
224 (
225 "*** Begin Patch\n"
226 "*** Update File: sample.txt\n"
227 "@@ alpha\n"
228 "+one\n"
229 "*** Update File: other.txt\n"
230 "@@ beta\n"
231 "+two\n"
232 "*** End Patch"
233 ),
234 "may update only one file",
235 ),
236 ],
237 )
238 def test_context_patch_rejects_unsupported_file_operations(
239 tmp_path: Path, patch_text: str, expected: str
240 ) -> None:
241 target = tmp_path / "sample.txt"
242 target.write_text("alpha\nbeta\n", encoding="utf-8")
243
244 with pytest.raises(ContextPatchError, match=expected):
245 apply_context_patch_file(str(target), patch_text)
246
247
248 def test_patch_request_rejects_edits_and_patch_text_together() -> None:
249 request, err = parse_patch_request(
250 [{"from": 1, "to": 1, "content": "x\n"}],
251 "@@ alpha\n+beta",
252 )
253
254 assert request is None
255 assert err == "provide exactly one patch form: edits, patch_text, or old_text/new_text"
256
257
258 def test_patch_request_rejects_empty_patch_text() -> None:
259 request, err = parse_patch_request(None, " \n")
260
261 assert request is None
262 assert err == "patch_text must not be empty"
263
264
265 def test_patch_request_accepts_exact_replace() -> None:
266 request, err = parse_patch_request(
267 None,
268 None,
269 old_text="status = draft",
270 new_text="status = ready",
271 )
272
273 assert err == ""
274 assert request is not None
275 assert request.mode == "replace"
276 assert request.old_text == "status = draft"
277 assert request.new_text == "status = ready"
278
279
280 def test_patch_state_records_and_checks_fresh_file_state() -> None:
281 agent = _FakeAgent()
282 file_data = {"realpath": "/tmp/sample.txt", "mtime": 1.0, "total_lines": 3}
283
284 record_file_state(agent, file_data, key=LOCAL_FRESHNESS_KEY)
285
286 assert check_patch_freshness(agent, file_data, key=LOCAL_FRESHNESS_KEY) is None
287 assert check_patch_freshness(
288 agent,
289 {"realpath": "/tmp/sample.txt", "mtime": 2.0, "total_lines": 3},
290 key=LOCAL_FRESHNESS_KEY,
291 ) == "patch_stale_read"
292
293
294 def test_patch_state_marks_context_patches_stale() -> None:
295 agent = _FakeAgent()
296 file_data = {"realpath": "/tmp/sample.txt", "mtime": 1.0, "total_lines": 3}
297
298 record_file_state(agent, file_data, key=LOCAL_FRESHNESS_KEY)
299 mark_file_state_stale(agent, file_data, key=LOCAL_FRESHNESS_KEY)
300
301 assert agent.data[LOCAL_FRESHNESS_KEY]["/tmp/sample.txt"] == {
302 "mtime": 0,
303 "total_lines": 0,
304 }
305
306
307 def test_patch_state_line_preserving_edits_can_chain() -> None:
308 agent = _FakeAgent()
309 initial = {"realpath": "/tmp/sample.txt", "mtime": 1.0, "total_lines": 3}
310 patched = {"realpath": "/tmp/sample.txt", "mtime": 2.0, "total_lines": 3}
311 edits = [{"from": 2, "to": 2, "content": "line-2a\n"}]
312
313 record_file_state(agent, initial, key=LOCAL_FRESHNESS_KEY)
314 apply_patch_post_state(agent, patched, edits, key=LOCAL_FRESHNESS_KEY)
315
316 assert agent.data[LOCAL_FRESHNESS_KEY]["/tmp/sample.txt"] == {
317 "mtime": 2.0,
318 "total_lines": 3,
319 }
320 assert check_patch_freshness(agent, patched, key=LOCAL_FRESHNESS_KEY) is None
321
322
323 def test_patch_state_line_count_changes_force_reread() -> None:
324 agent = _FakeAgent()
325 initial = {"realpath": "/tmp/sample.txt", "mtime": 1.0, "total_lines": 3}
326 patched = {"realpath": "/tmp/sample.txt", "mtime": 2.0, "total_lines": 4}
327 edits = [{"from": 2, "content": "inserted\n"}]
328
329 record_file_state(agent, initial, key=LOCAL_FRESHNESS_KEY)
330 apply_patch_post_state(agent, patched, edits, key=LOCAL_FRESHNESS_KEY)
331
332 assert agent.data[LOCAL_FRESHNESS_KEY]["/tmp/sample.txt"] == {
333 "mtime": 0,
334 "total_lines": 0,
335 }
336
337
338 def test_patch_state_uses_separate_local_and_remote_keys() -> None:
339 agent = _FakeAgent()
340 file_data = {"realpath": "/tmp/sample.txt", "mtime": 1.0, "total_lines": 3}
341
342 record_file_state(agent, file_data, key=LOCAL_FRESHNESS_KEY)
343 mark_file_state_stale(agent, file_data, key=REMOTE_FRESHNESS_KEY)
344
345 assert agent.data[LOCAL_FRESHNESS_KEY]["/tmp/sample.txt"] == {
346 "mtime": 1.0,
347 "total_lines": 3,
348 }
349 assert agent.data[REMOTE_FRESHNESS_KEY]["/tmp/sample.txt"] == {
350 "mtime": 0,
351 "total_lines": 0,
352 }
353
354
355 @dataclass
356 class _FakeResponse:
357 message: str
358 break_loop: bool
359 additional: dict | None = None
360
361
362 class _FakeTool:
363 def __init__(
364 self,
365 agent,
366 name: str = "text_editor",
367 method: str = "patch",
368 args: dict | None = None,
369 message: str = "",
370 loop_data=None,
371 **kwargs,
372 ) -> None:
373 self.agent = agent
374 self.name = name
375 self.method = method
376 self.args = args or {}
377 self.message = message
378 self.loop_data = loop_data
379
380
381 class _FakeAgent:
382 def __init__(self, context_id: str = "") -> None:
383 self.data = {}
384 self.context = types.SimpleNamespace(id=context_id) if context_id else None
385
386 def read_prompt(self, name: str, **kwargs) -> str:
387 if name.endswith("read_ok.md"):
388 return (
389 f"{kwargs['path']} read {kwargs['total_lines']} lines\n"
390 f">>>\n{kwargs['content']}\n<<<"
391 )
392 if name.endswith("patch_ok.md"):
393 return (
394 f"{kwargs['path']} patched {kwargs['edit_count']} edits applied "
395 f"{kwargs['total_lines']} lines now\n>>>\n{kwargs['content']}\n<<<"
396 )
397 if name.endswith("patch_need_read.md"):
398 return f"must read {kwargs['path']} first"
399 if name.endswith("patch_stale_read.md"):
400 return f"stale read for {kwargs['path']}"
401 return f"error patching {kwargs.get('path')}: {kwargs.get('error')}"
402
403
404 def _load_text_editor_tool(monkeypatch: pytest.MonkeyPatch):
405 calls: list[tuple[str, dict | None]] = []
406 import helpers
407
408 tool_stub = types.ModuleType("helpers.tool")
409 tool_stub.Tool = _FakeTool
410 tool_stub.Response = _FakeResponse
411
412 extension_stub = types.ModuleType("helpers.extension")
413
414 async def call_extensions_async(name: str, *args, **kwargs):
415 calls.append((name, kwargs.get("data")))
416
417 extension_stub.call_extensions_async = call_extensions_async
418
419 plugins_stub = types.ModuleType("helpers.plugins")
420 plugins_stub.get_plugin_config = lambda *args, **kwargs: {}
421
422 runtime_stub = types.ModuleType("helpers.runtime")
423
424 async def call_development_function(func, *args, **kwargs):
425 result = func(*args, **kwargs)
426 if inspect.isawaitable(result):
427 return await result
428 return result
429
430 runtime_stub.call_development_function = call_development_function
431
432 monkeypatch.setitem(sys.modules, "helpers.tool", tool_stub)
433 monkeypatch.setitem(sys.modules, "helpers.extension", extension_stub)
434 monkeypatch.setitem(sys.modules, "helpers.plugins", plugins_stub)
435 monkeypatch.setitem(sys.modules, "helpers.runtime", runtime_stub)
436 monkeypatch.setattr(helpers, "extension", extension_stub, raising=False)
437 monkeypatch.setattr(helpers, "plugins", plugins_stub, raising=False)
438 monkeypatch.setattr(helpers, "runtime", runtime_stub, raising=False)
439 sys.modules.pop("plugins._text_editor.tools.text_editor", None)
440 module = importlib.import_module("plugins._text_editor.tools.text_editor")
441 return module, calls
442
443
444 def test_text_editor_patch_text_does_not_require_prior_read(
445 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
446 ) -> None:
447 module, calls = _load_text_editor_tool(monkeypatch)
448 target = tmp_path / "sample.txt"
449 target.write_text("line-1\nline-2\nline-3\n", encoding="utf-8")
450 agent = _FakeAgent()
451 tool = module.TextEditor(agent, "text_editor", "patch", {}, "", None)
452
453 response = asyncio.run(
454 tool._patch(
455 path=str(target),
456 patch_text=(
457 "*** Begin Patch\n"
458 "*** Update File: sample.txt\n"
459 "@@ line-1\n"
460 "+inserted\n"
461 "*** End Patch"
462 ),
463 )
464 )
465
466 assert "patched 1 edits applied 4 lines now" in response.message
467 assert "inserted" in response.message
468 assert target.read_text(encoding="utf-8") == (
469 "line-1\ninserted\nline-2\nline-3\n"
470 )
471 realpath = os.path.realpath(target)
472 assert agent.data[module._MTIME_KEY][realpath] == {
473 "mtime": 0,
474 "total_lines": 0,
475 }
476 assert calls[0] == (
477 "text_editor_patch_before",
478 {
479 "path": str(target),
480 "patch_text": (
481 "*** Begin Patch\n"
482 "*** Update File: sample.txt\n"
483 "@@ line-1\n"
484 "+inserted\n"
485 "*** End Patch"
486 ),
487 "edits": [],
488 "mode": "patch_text",
489 },
490 )
491 assert calls[1][0] == "text_editor_patch_after"
492 assert calls[1][1]["mode"] == "patch_text"
493
494
495 def test_text_editor_exact_replace_does_not_require_prior_read(
496 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
497 ) -> None:
498 module, calls = _load_text_editor_tool(monkeypatch)
499 target = tmp_path / "sample.txt"
500 target.write_text("line-1\nstatus = draft\nline-3\n", encoding="utf-8")
501 agent = _FakeAgent()
502 tool = module.TextEditor(agent, "text_editor", "patch", {}, "", None)
503
504 response = asyncio.run(
505 tool._patch(
506 path=str(target),
507 old_text="status = draft",
508 new_text="status = ready",
509 )
510 )
511
512 assert "patched 1 edits applied 3 lines now" in response.message
513 assert "status = ready" in response.message
514 assert target.read_text(encoding="utf-8") == "line-1\nstatus = ready\nline-3\n"
515 realpath = os.path.realpath(target)
516 assert agent.data[module._MTIME_KEY][realpath] == {
517 "mtime": 0,
518 "total_lines": 0,
519 }
520 assert calls[0] == (
521 "text_editor_patch_before",
522 {
523 "path": str(target),
524 "old_text": "status = draft",
525 "new_text": "status = ready",
526 "edits": [],
527 "mode": "replace",
528 },
529 )
530 assert calls[1][0] == "text_editor_patch_after"
531 assert calls[1][1]["mode"] == "replace"
532
533
534 def test_text_editor_execute_accepts_action_alias_for_read(
535 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
536 ) -> None:
537 module, _calls = _load_text_editor_tool(monkeypatch)
538 target = tmp_path / "sample.txt"
539 target.write_text("line-1\nline-2\n", encoding="utf-8")
540 tool = module.TextEditor(
541 _FakeAgent(),
542 "text_editor",
543 None,
544 {"action": "read", "path": str(target), "line_from": 1, "line_to": 1},
545 "",
546 None,
547 )
548
549 response = asyncio.run(tool.execute(**tool.args))
550
551 assert "read 2 lines" in response.message
552 assert "line-1" in response.message
553
554
555 def test_text_editor_write_result_carries_markdown_canvas_intent(
556 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
557 ) -> None:
558 module, _calls = _load_text_editor_tool(monkeypatch)
559 target = tmp_path / "note.md"
560 tool = module.TextEditor(_FakeAgent("ctx-write-1"), "text_editor", "write", {}, "", None)
561
562 response = asyncio.run(
563 tool._write(
564 path=str(target),
565 content="# Note\n",
566 open_in_canvas=True,
567 )
568 )
569
570 assert target.read_text(encoding="utf-8") == "# Note\n"
571 assert response.additional == {
572 "_tool_name": "text_editor",
573 "action": "write",
574 "path": str(target),
575 "format": "md",
576 "extension": "md",
577 "open_in_canvas": True,
578 "context_id": "ctx-write-1",
579 "ctxid": "ctx-write-1",
580 }
581
582
583 def test_text_editor_patch_text_rejects_simultaneous_edits(
584 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
585 ) -> None:
586 module, _calls = _load_text_editor_tool(monkeypatch)
587 target = tmp_path / "sample.txt"
588 target.write_text("line-1\n", encoding="utf-8")
589 tool = module.TextEditor(_FakeAgent(), "text_editor", "patch", {}, "", None)
590
591 response = asyncio.run(
592 tool._patch(
593 path=str(target),
594 edits=[{"from": 1, "to": 1, "content": "updated\n"}],
595 patch_text="@@ line-1\n+inserted",
596 )
597 )
598
599 assert "provide exactly one patch form" in response.message
600 assert target.read_text(encoding="utf-8") == "line-1\n"
601
602
603 def test_text_editor_patch_text_marks_existing_line_state_stale(
604 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
605 ) -> None:
606 module, _calls = _load_text_editor_tool(monkeypatch)
607 target = tmp_path / "sample.txt"
608 target.write_text("line-1\nline-2\n", encoding="utf-8")
609 realpath = os.path.realpath(target)
610 agent = _FakeAgent()
611 agent.data[module._MTIME_KEY] = {
612 realpath: {"mtime": os.path.getmtime(target), "total_lines": 2}
613 }
614 tool = module.TextEditor(agent, "text_editor", "patch", {}, "", None)
615
616 asyncio.run(
617 tool._patch(
618 path=str(target),
619 patch_text=(
620 "*** Begin Patch\n"
621 "*** Update File: sample.txt\n"
622 "@@ line-1\n"
623 "+inserted\n"
624 "*** End Patch"
625 ),
626 )
627 )
628
629 assert agent.data[module._MTIME_KEY][realpath] == {
630 "mtime": 0,
631 "total_lines": 0,
632 }
633
634
635 def test_text_editor_line_edits_still_require_prior_read(
636 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
637 ) -> None:
638 module, calls = _load_text_editor_tool(monkeypatch)
639 target = tmp_path / "sample.txt"
640 target.write_text("line-1\n", encoding="utf-8")
641 tool = module.TextEditor(_FakeAgent(), "text_editor", "patch", {}, "", None)
642
643 response = asyncio.run(
644 tool._patch(
645 path=str(target),
646 edits=[{"from": 1, "to": 1, "content": "updated\n"}],
647 )
648 )
649
650 assert "must read" in response.message
651 assert target.read_text(encoding="utf-8") == "line-1\n"
652 assert calls == []