feat(tools): optimize coding workflows
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from nanobot.agent.tools.apply_patch import ApplyPatchTool
|
||||
|
||||
|
||||
def test_apply_patch_adds_file(tmp_path):
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
patch="""*** Begin Patch
|
||||
*** Add File: hello.txt
|
||||
+Hello
|
||||
+world
|
||||
*** End Patch
|
||||
"""
|
||||
))
|
||||
|
||||
assert "Patch applied" in result
|
||||
assert (tmp_path / "hello.txt").read_text() == "Hello\nworld\n"
|
||||
|
||||
|
||||
def test_apply_patch_updates_multiple_hunks(tmp_path):
|
||||
target = tmp_path / "multi.txt"
|
||||
target.write_text("line1\nline2\nline3\nline4\n")
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
patch="""*** Begin Patch
|
||||
*** Update File: multi.txt
|
||||
@@
|
||||
-line2
|
||||
+changed2
|
||||
@@
|
||||
-line4
|
||||
+changed4
|
||||
*** End Patch
|
||||
"""
|
||||
))
|
||||
|
||||
assert "update multi.txt" in result
|
||||
assert target.read_text() == "line1\nchanged2\nline3\nchanged4\n"
|
||||
|
||||
|
||||
def test_apply_patch_ignores_standard_no_newline_marker(tmp_path):
|
||||
target = tmp_path / "plain.txt"
|
||||
target.write_text("before")
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
patch="""*** Begin Patch
|
||||
*** Update File: plain.txt
|
||||
@@ -1,1 +1,1 @@
|
||||
-before
|
||||
\\ No newline at end of file
|
||||
+after
|
||||
\\ No newline at end of file
|
||||
*** End Patch
|
||||
"""
|
||||
))
|
||||
|
||||
assert "update plain.txt" in result
|
||||
assert target.read_text() == "after\n"
|
||||
|
||||
|
||||
def test_apply_patch_rejects_empty_hunk(tmp_path):
|
||||
target = tmp_path / "plain.txt"
|
||||
target.write_text("before\n")
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
patch="""*** Begin Patch
|
||||
*** Update File: plain.txt
|
||||
@@
|
||||
*** End Patch
|
||||
"""
|
||||
))
|
||||
|
||||
assert "hunk is empty" in result
|
||||
assert target.read_text() == "before\n"
|
||||
|
||||
|
||||
def test_apply_patch_uses_unified_diff_line_hint(tmp_path):
|
||||
target = tmp_path / "repeated.txt"
|
||||
target.write_text("target\nmiddle\ntarget\n")
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
patch="""*** Begin Patch
|
||||
*** Update File: repeated.txt
|
||||
@@ -3,1 +3,1 @@
|
||||
-target
|
||||
+changed
|
||||
*** End Patch
|
||||
"""
|
||||
))
|
||||
|
||||
assert "update repeated.txt" in result
|
||||
assert target.read_text() == "target\nmiddle\nchanged\n"
|
||||
|
||||
|
||||
def test_apply_patch_line_hint_does_not_fallback_to_earlier_match(tmp_path):
|
||||
target = tmp_path / "repeated.txt"
|
||||
target.write_text("target\nmiddle\nother\n")
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
patch="""*** Begin Patch
|
||||
*** Update File: repeated.txt
|
||||
@@ -3,1 +3,1 @@
|
||||
-target
|
||||
+changed
|
||||
*** End Patch
|
||||
"""
|
||||
))
|
||||
|
||||
assert "hunk does not match repeated.txt" in result
|
||||
assert target.read_text() == "target\nmiddle\nother\n"
|
||||
|
||||
|
||||
def test_apply_patch_mismatch_reports_best_match(tmp_path):
|
||||
target = tmp_path / "near.txt"
|
||||
target.write_text("alpha\nbeta\ngamma\n")
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
patch="""*** Begin Patch
|
||||
*** Update File: near.txt
|
||||
@@ -2,1 +2,1 @@
|
||||
-betx
|
||||
+delta
|
||||
*** End Patch
|
||||
"""
|
||||
))
|
||||
|
||||
assert "hunk does not match near.txt" in result
|
||||
assert "Best match" in result
|
||||
assert "line 2" in result
|
||||
assert target.read_text() == "alpha\nbeta\ngamma\n"
|
||||
|
||||
|
||||
def test_apply_patch_moves_and_updates_file(tmp_path):
|
||||
source = tmp_path / "old" / "name.txt"
|
||||
source.parent.mkdir()
|
||||
source.write_text("old content\n")
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
patch="""*** Begin Patch
|
||||
*** Update File: old/name.txt
|
||||
*** Move to: renamed/dir/name.txt
|
||||
@@
|
||||
-old content
|
||||
+new content
|
||||
*** End Patch
|
||||
"""
|
||||
))
|
||||
|
||||
assert "move old/name.txt -> renamed/dir/name.txt" in result
|
||||
assert not source.exists()
|
||||
assert (tmp_path / "renamed" / "dir" / "name.txt").read_text() == "new content\n"
|
||||
|
||||
|
||||
def test_apply_patch_deletes_file(tmp_path):
|
||||
target = tmp_path / "obsolete.txt"
|
||||
target.write_text("remove me\n")
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
patch="""*** Begin Patch
|
||||
*** Delete File: obsolete.txt
|
||||
*** End Patch
|
||||
"""
|
||||
))
|
||||
|
||||
assert "delete obsolete.txt" in result
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_apply_patch_rejects_absolute_and_parent_paths(tmp_path):
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
|
||||
absolute = asyncio.run(tool.execute(
|
||||
patch="""*** Begin Patch
|
||||
*** Add File: /tmp/owned.txt
|
||||
+nope
|
||||
*** End Patch
|
||||
"""
|
||||
))
|
||||
parent = asyncio.run(tool.execute(
|
||||
patch="""*** Begin Patch
|
||||
*** Add File: ../owned.txt
|
||||
+nope
|
||||
*** End Patch
|
||||
"""
|
||||
))
|
||||
|
||||
assert "must be relative" in absolute
|
||||
assert "must not contain '..'" in parent
|
||||
assert not (tmp_path.parent / "owned.txt").exists()
|
||||
|
||||
|
||||
def test_apply_patch_does_not_overwrite_existing_file_with_add(tmp_path):
|
||||
target = tmp_path / "existing.txt"
|
||||
target.write_text("keep me\n")
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
patch="""*** Begin Patch
|
||||
*** Add File: existing.txt
|
||||
+replace me
|
||||
*** End Patch
|
||||
"""
|
||||
))
|
||||
|
||||
assert "file to add already exists" in result
|
||||
assert target.read_text() == "keep me\n"
|
||||
|
||||
|
||||
def test_apply_patch_rolls_back_when_late_operation_fails(tmp_path):
|
||||
first = tmp_path / "first.txt"
|
||||
first.write_text("before\n")
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
patch="""*** Begin Patch
|
||||
*** Update File: first.txt
|
||||
@@
|
||||
-before
|
||||
+after
|
||||
*** Delete File: missing.txt
|
||||
*** End Patch
|
||||
"""
|
||||
))
|
||||
|
||||
assert "file to delete does not exist" in result
|
||||
assert first.read_text() == "before\n"
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Tests for EditFileTool enhancements: read-before-edit tracking, path suggestions,
|
||||
.ipynb detection, and create-file semantics."""
|
||||
notebook JSON editing, and create-file semantics."""
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -108,22 +108,27 @@ class TestEditCreateFile:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# .ipynb detection
|
||||
# .ipynb editing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEditIpynbDetection:
|
||||
"""edit_file should refuse .ipynb and suggest notebook_edit."""
|
||||
class TestEditIpynbFiles:
|
||||
"""edit_file edits notebooks as normal JSON files."""
|
||||
|
||||
@pytest.fixture()
|
||||
def tool(self, tmp_path):
|
||||
return EditFileTool(workspace=tmp_path)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ipynb_rejected_with_suggestion(self, tool, tmp_path):
|
||||
async def test_ipynb_can_be_edited_as_json(self, tool, tmp_path):
|
||||
f = tmp_path / "analysis.ipynb"
|
||||
f.write_text('{"cells": []}', encoding="utf-8")
|
||||
result = await tool.execute(path=str(f), old_text="x", new_text="y")
|
||||
assert "notebook" in result.lower()
|
||||
result = await tool.execute(
|
||||
path=str(f),
|
||||
old_text='"cells": []',
|
||||
new_text='"cells": [{"cell_type": "markdown", "source": "hi"}]',
|
||||
)
|
||||
assert "Successfully edited" in result
|
||||
assert '"source": "hi"' in f.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.agent.tools.exec_session import ExecSessionManager, WriteStdinTool
|
||||
|
||||
|
||||
def _python_command(code: str) -> str:
|
||||
if sys.platform == "win32":
|
||||
return f"{subprocess.list2cmdline([sys.executable])} -u -c {subprocess.list2cmdline([code])}"
|
||||
return f"{shlex.quote(sys.executable)} -u -c {shlex.quote(code)}"
|
||||
|
||||
|
||||
def _session_id(output: str) -> str:
|
||||
match = re.search(r"session_id:\s*([0-9a-f]+)", output)
|
||||
assert match, output
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_exec_keeps_one_shot_behavior_without_yield_time_ms(tmp_path):
|
||||
async def run() -> str:
|
||||
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||
return await tool.execute(command="echo hello")
|
||||
|
||||
result = asyncio.run(run())
|
||||
|
||||
assert "hello" in result
|
||||
assert "Exit code: 0" in result
|
||||
assert "session_id:" not in result
|
||||
|
||||
|
||||
def test_exec_accepts_command_aliases(tmp_path):
|
||||
async def run() -> str:
|
||||
tool = ExecTool(working_dir="/")
|
||||
return await tool.execute(cmd="pwd", workdir=str(tmp_path))
|
||||
|
||||
result = asyncio.run(run())
|
||||
|
||||
assert str(tmp_path) in result
|
||||
assert "Exit code: 0" in result
|
||||
|
||||
|
||||
def test_exec_returns_completed_session_output_when_yield_time_ms_is_used(tmp_path):
|
||||
async def run() -> str:
|
||||
manager = ExecSessionManager()
|
||||
tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||
stdin_tool = WriteStdinTool(manager=manager)
|
||||
|
||||
result = await tool.execute(command="echo hello", yield_time_ms=1000)
|
||||
if "session_id:" in result:
|
||||
sid = _session_id(result)
|
||||
result += "\n" + await stdin_tool.execute(
|
||||
session_id=sid,
|
||||
chars="",
|
||||
yield_time_ms=1000,
|
||||
)
|
||||
return result
|
||||
|
||||
result = asyncio.run(run())
|
||||
|
||||
assert "hello" in result
|
||||
assert "Exit code: 0" in result
|
||||
assert "session_id:" not in result
|
||||
|
||||
|
||||
def test_exec_session_accepts_max_output_tokens_alias(tmp_path):
|
||||
async def run() -> str:
|
||||
manager = ExecSessionManager()
|
||||
tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||
command = _python_command("print('A' * 2000)")
|
||||
return await tool.execute(
|
||||
command=command,
|
||||
yield_time_ms=1000,
|
||||
max_output_tokens=1000,
|
||||
)
|
||||
|
||||
result = asyncio.run(run())
|
||||
|
||||
assert "chars truncated" in result
|
||||
assert "Exit code: 0" in result
|
||||
|
||||
|
||||
def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path):
|
||||
async def run() -> str:
|
||||
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||
command = _python_command("print('A' * 2000)")
|
||||
return await tool.execute(command=command, max_output_tokens=1000)
|
||||
|
||||
result = asyncio.run(run())
|
||||
|
||||
assert "chars truncated" in result
|
||||
assert "Exit code: 0" in result
|
||||
|
||||
|
||||
def test_exec_accepts_supported_shell_parameter(tmp_path):
|
||||
async def run() -> str:
|
||||
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||
return await tool.execute(command="echo shell-ok", shell="sh", login=False)
|
||||
|
||||
if sys.platform == "win32":
|
||||
return
|
||||
result = asyncio.run(run())
|
||||
|
||||
assert "shell-ok" in result
|
||||
assert "Exit code: 0" in result
|
||||
|
||||
|
||||
def test_exec_rejects_unsupported_shell(tmp_path):
|
||||
async def run() -> str:
|
||||
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||
return await tool.execute(command="echo no", shell="python")
|
||||
|
||||
if sys.platform == "win32":
|
||||
return
|
||||
result = asyncio.run(run())
|
||||
|
||||
assert "unsupported shell" in result
|
||||
|
||||
|
||||
def test_exec_can_continue_with_stdin(tmp_path):
|
||||
async def run() -> tuple[str, str]:
|
||||
manager = ExecSessionManager()
|
||||
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||
stdin_tool = WriteStdinTool(manager=manager)
|
||||
command = _python_command(
|
||||
"import sys; print('ready', flush=True); "
|
||||
"line=sys.stdin.readline(); print('got:' + line.strip(), flush=True)"
|
||||
)
|
||||
|
||||
initial = await exec_tool.execute(command=command, yield_time_ms=500)
|
||||
sid = _session_id(initial)
|
||||
result = await stdin_tool.execute(session_id=sid, chars="ping\n", yield_time_ms=1000)
|
||||
return initial, result
|
||||
|
||||
initial, result = asyncio.run(run())
|
||||
assert "ready" in initial
|
||||
assert "Process running" in initial
|
||||
assert "got:ping" in result
|
||||
assert "Exit code: 0" in result
|
||||
|
||||
|
||||
def test_write_stdin_can_close_stdin(tmp_path):
|
||||
async def run() -> tuple[str, str]:
|
||||
manager = ExecSessionManager()
|
||||
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||
stdin_tool = WriteStdinTool(manager=manager)
|
||||
command = _python_command(
|
||||
"import sys; print('ready', flush=True); "
|
||||
"data=sys.stdin.read(); print('got:' + data, flush=True)"
|
||||
)
|
||||
|
||||
initial = await exec_tool.execute(command=command, yield_time_ms=500)
|
||||
sid = _session_id(initial)
|
||||
result = await stdin_tool.execute(
|
||||
session_id=sid,
|
||||
chars="payload",
|
||||
close_stdin=True,
|
||||
yield_time_ms=1000,
|
||||
)
|
||||
return initial, result
|
||||
|
||||
initial, result = asyncio.run(run())
|
||||
assert "ready" in initial
|
||||
assert "got:payload" in result
|
||||
assert "Stdin closed." in result
|
||||
assert "Exit code: 0" in result
|
||||
|
||||
|
||||
def test_write_stdin_can_terminate_session(tmp_path):
|
||||
async def run() -> tuple[str, str]:
|
||||
manager = ExecSessionManager()
|
||||
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager)
|
||||
stdin_tool = WriteStdinTool(manager=manager)
|
||||
command = _python_command(
|
||||
"import time; print('ready', flush=True); time.sleep(30)"
|
||||
)
|
||||
|
||||
initial = await exec_tool.execute(command=command, yield_time_ms=500)
|
||||
sid = _session_id(initial)
|
||||
result = await stdin_tool.execute(
|
||||
session_id=sid,
|
||||
terminate=True,
|
||||
yield_time_ms=0,
|
||||
)
|
||||
return initial, result
|
||||
|
||||
initial, result = asyncio.run(run())
|
||||
assert "ready" in initial
|
||||
assert "Session terminated." in result
|
||||
assert "Exit code:" in result
|
||||
|
||||
|
||||
def test_write_stdin_accepts_max_output_tokens_alias(tmp_path):
|
||||
async def run() -> tuple[str, str, str]:
|
||||
manager = ExecSessionManager()
|
||||
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||
stdin_tool = WriteStdinTool(manager=manager)
|
||||
command = _python_command(
|
||||
"import time; print('A' * 2000, flush=True); time.sleep(5)"
|
||||
)
|
||||
|
||||
initial = await exec_tool.execute(command=command, yield_time_ms=0)
|
||||
sid = _session_id(initial)
|
||||
poll = await stdin_tool.execute(
|
||||
session_id=sid,
|
||||
yield_time_ms=500,
|
||||
max_output_tokens=1000,
|
||||
)
|
||||
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
|
||||
return initial, poll, cleanup
|
||||
|
||||
initial, poll, cleanup = asyncio.run(run())
|
||||
assert "Process running" in initial
|
||||
assert "chars truncated" in poll
|
||||
assert "Session terminated." in cleanup
|
||||
|
||||
|
||||
def test_exec_session_mode_reuses_exec_safety_guard(tmp_path):
|
||||
manager = ExecSessionManager()
|
||||
tool = ExecTool(
|
||||
working_dir=str(tmp_path),
|
||||
deny_patterns=[r"echo\s+blocked"],
|
||||
session_manager=manager,
|
||||
)
|
||||
|
||||
result = asyncio.run(tool.execute(command="echo blocked", yield_time_ms=0))
|
||||
|
||||
assert "blocked by deny pattern" in result
|
||||
|
||||
|
||||
def test_write_stdin_reports_missing_session(tmp_path):
|
||||
manager = ExecSessionManager()
|
||||
tool = WriteStdinTool(manager=manager)
|
||||
|
||||
result = asyncio.run(tool.execute(session_id="missing", chars=""))
|
||||
|
||||
assert "exec session not found" in result
|
||||
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool
|
||||
|
||||
|
||||
def test_read_file_force_bypasses_dedup(tmp_path):
|
||||
target = tmp_path / "data.txt"
|
||||
target.write_text("alpha\n")
|
||||
tool = ReadFileTool(workspace=tmp_path)
|
||||
|
||||
first = asyncio.run(tool.execute(path=str(target)))
|
||||
second = asyncio.run(tool.execute(path=str(target)))
|
||||
forced = asyncio.run(tool.execute(path=str(target), force=True))
|
||||
|
||||
assert "alpha" in first
|
||||
assert "unchanged" in second.lower()
|
||||
assert "alpha" in forced
|
||||
assert "unchanged" not in forced.lower()
|
||||
|
||||
|
||||
def test_edit_file_can_select_occurrence(tmp_path):
|
||||
target = tmp_path / "duplicate.txt"
|
||||
target.write_text("one\nsame\ntwo\nsame\n")
|
||||
tool = EditFileTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
path=str(target),
|
||||
old_text="same",
|
||||
new_text="changed",
|
||||
occurrence=2,
|
||||
))
|
||||
|
||||
assert "Successfully edited" in result
|
||||
assert target.read_text() == "one\nsame\ntwo\nchanged\n"
|
||||
|
||||
|
||||
def test_edit_file_expected_replacements_guards_replace_all(tmp_path):
|
||||
target = tmp_path / "duplicate.txt"
|
||||
target.write_text("same\nsame\n")
|
||||
tool = EditFileTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
path=str(target),
|
||||
old_text="same",
|
||||
new_text="changed",
|
||||
replace_all=True,
|
||||
expected_replacements=1,
|
||||
))
|
||||
|
||||
assert "expected 1 replacements but would make 2" in result
|
||||
assert target.read_text() == "same\nsame\n"
|
||||
|
||||
|
||||
def test_edit_file_expected_replacements_allows_replace_all_when_count_matches(tmp_path):
|
||||
target = tmp_path / "duplicate.txt"
|
||||
target.write_text("same\nsame\n")
|
||||
tool = EditFileTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
path=str(target),
|
||||
old_text="same",
|
||||
new_text="changed",
|
||||
replace_all=True,
|
||||
expected_replacements=2,
|
||||
))
|
||||
|
||||
assert "Successfully edited" in result
|
||||
assert target.read_text() == "changed\nchanged\n"
|
||||
|
||||
|
||||
def test_edit_file_can_select_nearest_line_hint(tmp_path):
|
||||
target = tmp_path / "duplicate.txt"
|
||||
target.write_text("one\nsame\ntwo\nsame\n")
|
||||
tool = EditFileTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
path=str(target),
|
||||
old_text="same",
|
||||
new_text="changed",
|
||||
line_hint=4,
|
||||
))
|
||||
|
||||
assert "Successfully edited" in result
|
||||
assert target.read_text() == "one\nsame\ntwo\nchanged\n"
|
||||
|
||||
|
||||
def test_edit_file_can_edit_ipynb_as_json(tmp_path):
|
||||
target = tmp_path / "analysis.ipynb"
|
||||
target.write_text('{"cells": []}')
|
||||
tool = EditFileTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
path=str(target),
|
||||
old_text='"cells": []',
|
||||
new_text='"cells": [{"cell_type": "markdown", "source": "hi"}]',
|
||||
))
|
||||
|
||||
assert "Successfully edited" in result
|
||||
assert '"source": "hi"' in target.read_text()
|
||||
|
||||
|
||||
def test_edit_file_multiple_match_hint_mentions_occurrence(tmp_path):
|
||||
target = tmp_path / "duplicate.txt"
|
||||
target.write_text("same\nsame\n")
|
||||
tool = EditFileTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
path=str(target),
|
||||
old_text="same",
|
||||
new_text="changed",
|
||||
))
|
||||
|
||||
assert "old_text appears 2 times" in result
|
||||
assert "occurrence" in result
|
||||
assert target.read_text() == "same\nsame\n"
|
||||
|
||||
|
||||
def test_edit_file_rejects_ambiguous_line_hint(tmp_path):
|
||||
target = tmp_path / "duplicate.txt"
|
||||
target.write_text("same\nmiddle\nsame\n")
|
||||
tool = EditFileTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
path=str(target),
|
||||
old_text="same",
|
||||
new_text="changed",
|
||||
line_hint=2,
|
||||
))
|
||||
|
||||
assert "line_hint 2 is ambiguous" in result
|
||||
assert target.read_text() == "same\nmiddle\nsame\n"
|
||||
|
||||
|
||||
def test_edit_file_rejects_occurrence_with_replace_all(tmp_path):
|
||||
target = tmp_path / "duplicate.txt"
|
||||
target.write_text("same\nsame\n")
|
||||
tool = EditFileTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
path=str(target),
|
||||
old_text="same",
|
||||
new_text="changed",
|
||||
occurrence=1,
|
||||
replace_all=True,
|
||||
))
|
||||
|
||||
assert "occurrence cannot be used with replace_all" in result
|
||||
assert target.read_text() == "same\nsame\n"
|
||||
|
||||
|
||||
def test_edit_file_rejects_line_hint_with_replace_all(tmp_path):
|
||||
target = tmp_path / "duplicate.txt"
|
||||
target.write_text("same\nsame\n")
|
||||
tool = EditFileTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
path=str(target),
|
||||
old_text="same",
|
||||
new_text="changed",
|
||||
line_hint=1,
|
||||
replace_all=True,
|
||||
))
|
||||
|
||||
assert "line_hint cannot be used with replace_all" in result
|
||||
assert target.read_text() == "same\nsame\n"
|
||||
|
||||
|
||||
def test_edit_file_rejects_line_hint_with_occurrence(tmp_path):
|
||||
target = tmp_path / "duplicate.txt"
|
||||
target.write_text("same\nsame\n")
|
||||
tool = EditFileTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
path=str(target),
|
||||
old_text="same",
|
||||
new_text="changed",
|
||||
occurrence=1,
|
||||
line_hint=1,
|
||||
))
|
||||
|
||||
assert "line_hint cannot be used with occurrence" in result
|
||||
assert target.read_text() == "same\nsame\n"
|
||||
|
||||
|
||||
def test_edit_file_rejects_zero_occurrence(tmp_path):
|
||||
target = tmp_path / "duplicate.txt"
|
||||
target.write_text("same\n")
|
||||
tool = EditFileTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
path=str(target),
|
||||
old_text="same",
|
||||
new_text="changed",
|
||||
occurrence=0,
|
||||
))
|
||||
|
||||
assert "occurrence must be >= 1" in result
|
||||
assert target.read_text() == "same\n"
|
||||
|
||||
|
||||
def test_edit_file_rejects_zero_line_hint(tmp_path):
|
||||
target = tmp_path / "duplicate.txt"
|
||||
target.write_text("same\n")
|
||||
tool = EditFileTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(tool.execute(
|
||||
path=str(target),
|
||||
old_text="same",
|
||||
new_text="changed",
|
||||
line_hint=0,
|
||||
))
|
||||
|
||||
assert "line_hint must be >= 1" in result
|
||||
assert target.read_text() == "same\n"
|
||||
@@ -1,147 +0,0 @@
|
||||
"""Tests for NotebookEditTool — Jupyter .ipynb editing."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.notebook import NotebookEditTool
|
||||
|
||||
|
||||
def _make_notebook(cells: list[dict] | None = None, nbformat: int = 4, nbformat_minor: int = 5) -> dict:
|
||||
"""Build a minimal valid .ipynb structure."""
|
||||
return {
|
||||
"nbformat": nbformat,
|
||||
"nbformat_minor": nbformat_minor,
|
||||
"metadata": {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}},
|
||||
"cells": cells or [],
|
||||
}
|
||||
|
||||
|
||||
def _code_cell(source: str, cell_id: str | None = None) -> dict:
|
||||
cell = {"cell_type": "code", "source": source, "metadata": {}, "outputs": [], "execution_count": None}
|
||||
if cell_id:
|
||||
cell["id"] = cell_id
|
||||
return cell
|
||||
|
||||
|
||||
def _md_cell(source: str, cell_id: str | None = None) -> dict:
|
||||
cell = {"cell_type": "markdown", "source": source, "metadata": {}}
|
||||
if cell_id:
|
||||
cell["id"] = cell_id
|
||||
return cell
|
||||
|
||||
|
||||
def _write_nb(tmp_path, name: str, nb: dict) -> str:
|
||||
p = tmp_path / name
|
||||
p.write_text(json.dumps(nb), encoding="utf-8")
|
||||
return str(p)
|
||||
|
||||
|
||||
class TestNotebookEdit:
|
||||
|
||||
@pytest.fixture()
|
||||
def tool(self, tmp_path):
|
||||
return NotebookEditTool(workspace=tmp_path)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replace_cell_content(self, tool, tmp_path):
|
||||
nb = _make_notebook([_code_cell("print('hello')"), _code_cell("x = 1")])
|
||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
||||
result = await tool.execute(path=path, cell_index=0, new_source="print('world')")
|
||||
assert "Successfully" in result
|
||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
||||
assert saved["cells"][0]["source"] == "print('world')"
|
||||
assert saved["cells"][1]["source"] == "x = 1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_cell_after_target(self, tool, tmp_path):
|
||||
nb = _make_notebook([_code_cell("cell 0"), _code_cell("cell 1")])
|
||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
||||
result = await tool.execute(path=path, cell_index=0, new_source="inserted", edit_mode="insert")
|
||||
assert "Successfully" in result
|
||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
||||
assert len(saved["cells"]) == 3
|
||||
assert saved["cells"][0]["source"] == "cell 0"
|
||||
assert saved["cells"][1]["source"] == "inserted"
|
||||
assert saved["cells"][2]["source"] == "cell 1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_cell(self, tool, tmp_path):
|
||||
nb = _make_notebook([_code_cell("A"), _code_cell("B"), _code_cell("C")])
|
||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
||||
result = await tool.execute(path=path, cell_index=1, edit_mode="delete")
|
||||
assert "Successfully" in result
|
||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
||||
assert len(saved["cells"]) == 2
|
||||
assert saved["cells"][0]["source"] == "A"
|
||||
assert saved["cells"][1]["source"] == "C"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_new_notebook_from_scratch(self, tool, tmp_path):
|
||||
path = str(tmp_path / "new.ipynb")
|
||||
result = await tool.execute(path=path, cell_index=0, new_source="# Hello", edit_mode="insert", cell_type="markdown")
|
||||
assert "Successfully" in result or "created" in result.lower()
|
||||
saved = json.loads((tmp_path / "new.ipynb").read_text())
|
||||
assert saved["nbformat"] == 4
|
||||
assert len(saved["cells"]) == 1
|
||||
assert saved["cells"][0]["cell_type"] == "markdown"
|
||||
assert saved["cells"][0]["source"] == "# Hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_cell_index_error(self, tool, tmp_path):
|
||||
nb = _make_notebook([_code_cell("only cell")])
|
||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
||||
result = await tool.execute(path=path, cell_index=5, new_source="x")
|
||||
assert "Error" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_ipynb_rejected(self, tool, tmp_path):
|
||||
f = tmp_path / "script.py"
|
||||
f.write_text("pass")
|
||||
result = await tool.execute(path=str(f), cell_index=0, new_source="x")
|
||||
assert "Error" in result
|
||||
assert ".ipynb" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preserves_metadata_and_outputs(self, tool, tmp_path):
|
||||
cell = _code_cell("old")
|
||||
cell["outputs"] = [{"output_type": "stream", "text": "hello\n"}]
|
||||
cell["execution_count"] = 42
|
||||
nb = _make_notebook([cell])
|
||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
||||
await tool.execute(path=path, cell_index=0, new_source="new")
|
||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
||||
assert saved["metadata"]["kernelspec"]["language"] == "python"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nbformat_45_generates_cell_id(self, tool, tmp_path):
|
||||
nb = _make_notebook([], nbformat_minor=5)
|
||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
||||
await tool.execute(path=path, cell_index=0, new_source="x = 1", edit_mode="insert")
|
||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
||||
assert "id" in saved["cells"][0]
|
||||
assert len(saved["cells"][0]["id"]) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_with_cell_type_markdown(self, tool, tmp_path):
|
||||
nb = _make_notebook([_code_cell("code")])
|
||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
||||
await tool.execute(path=path, cell_index=0, new_source="# Title", edit_mode="insert", cell_type="markdown")
|
||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
||||
assert saved["cells"][1]["cell_type"] == "markdown"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_edit_mode_rejected(self, tool, tmp_path):
|
||||
nb = _make_notebook([_code_cell("code")])
|
||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
||||
result = await tool.execute(path=path, cell_index=0, new_source="x", edit_mode="replcae")
|
||||
assert "Error" in result
|
||||
assert "edit_mode" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_cell_type_rejected(self, tool, tmp_path):
|
||||
nb = _make_notebook([_code_cell("code")])
|
||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
||||
result = await tool.execute(path=path, cell_index=0, new_source="x", cell_type="raw")
|
||||
assert "Error" in result
|
||||
assert "cell_type" in result
|
||||
@@ -89,9 +89,11 @@ def test_discover_finds_concrete_tools():
|
||||
loader = ToolLoader()
|
||||
discovered = loader.discover()
|
||||
class_names = {cls.__name__ for cls in discovered}
|
||||
assert "ApplyPatchTool" in class_names
|
||||
assert "ExecTool" in class_names
|
||||
assert "MessageTool" in class_names
|
||||
assert "SpawnTool" in class_names
|
||||
assert "WriteStdinTool" in class_names
|
||||
|
||||
|
||||
def test_discover_excludes_abstract_and_mcp():
|
||||
@@ -406,7 +408,7 @@ def test_loader_registers_same_tools_as_old_hardcoded():
|
||||
|
||||
expected = {
|
||||
"read_file", "write_file", "edit_file", "list_dir",
|
||||
"grep", "notebook_edit", "exec", "web_search", "web_fetch",
|
||||
"grep", "exec", "web_search", "web_fetch",
|
||||
"message", "spawn", "cron",
|
||||
}
|
||||
actual = set(registered)
|
||||
|
||||
Reference in New Issue
Block a user