feat(tools): improve coding workflow recovery
This commit is contained in:
@@ -7,7 +7,7 @@ import subprocess
|
||||
import sys
|
||||
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.agent.tools.exec_session import ExecSessionManager, WriteStdinTool
|
||||
from nanobot.agent.tools.exec_session import ExecSessionManager, ListExecSessionsTool, WriteStdinTool
|
||||
|
||||
|
||||
def _python_command(code: str) -> str:
|
||||
@@ -140,8 +140,10 @@ def test_exec_can_continue_with_stdin(tmp_path):
|
||||
initial, result = asyncio.run(run())
|
||||
assert "ready" in initial
|
||||
assert "Process running" in initial
|
||||
assert "Elapsed:" in initial
|
||||
assert "got:ping" in result
|
||||
assert "Exit code: 0" in result
|
||||
assert "Elapsed:" in result
|
||||
|
||||
|
||||
def test_write_stdin_can_close_stdin(tmp_path):
|
||||
@@ -220,6 +222,29 @@ def test_write_stdin_accepts_max_output_tokens_alias(tmp_path):
|
||||
assert "Session terminated." in cleanup
|
||||
|
||||
|
||||
def test_write_stdin_preserves_completed_session_output_until_polled(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 time; print('ready', flush=True); "
|
||||
"time.sleep(1.0); print('done', flush=True)"
|
||||
)
|
||||
|
||||
initial = await exec_tool.execute(command=command, yield_time_ms=300)
|
||||
sid = _session_id(initial)
|
||||
await asyncio.sleep(1.2)
|
||||
final = await stdin_tool.execute(session_id=sid, chars="", yield_time_ms=0)
|
||||
return initial, final
|
||||
|
||||
initial, final = asyncio.run(run())
|
||||
|
||||
assert "ready" in initial
|
||||
assert "done" in final
|
||||
assert "Exit code: 0" in final
|
||||
|
||||
|
||||
def test_exec_session_mode_reuses_exec_safety_guard(tmp_path):
|
||||
manager = ExecSessionManager()
|
||||
tool = ExecTool(
|
||||
@@ -240,3 +265,35 @@ def test_write_stdin_reports_missing_session(tmp_path):
|
||||
result = asyncio.run(tool.execute(session_id="missing", chars=""))
|
||||
|
||||
assert "exec session not found" in result
|
||||
|
||||
|
||||
def test_list_exec_sessions_reports_running_commands(tmp_path):
|
||||
async def run() -> tuple[str, str, str]:
|
||||
manager = ExecSessionManager()
|
||||
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||
list_tool = ListExecSessionsTool(manager=manager)
|
||||
stdin_tool = WriteStdinTool(manager=manager)
|
||||
command = _python_command(
|
||||
"import time; print('ready', flush=True); time.sleep(5)"
|
||||
)
|
||||
|
||||
initial = await exec_tool.execute(command=command, yield_time_ms=500)
|
||||
sid = _session_id(initial)
|
||||
listing = await list_tool.execute()
|
||||
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
|
||||
return sid, listing, cleanup
|
||||
|
||||
sid, listing, cleanup = asyncio.run(run())
|
||||
|
||||
assert sid in listing
|
||||
assert "running" in listing
|
||||
assert "elapsed=" in listing
|
||||
assert "remaining=" in listing
|
||||
assert str(tmp_path) in listing
|
||||
assert "Session terminated." in cleanup
|
||||
|
||||
|
||||
def test_list_exec_sessions_reports_empty_state():
|
||||
result = asyncio.run(ListExecSessionsTool(manager=ExecSessionManager()).execute())
|
||||
|
||||
assert result == "No active exec sessions."
|
||||
|
||||
@@ -12,7 +12,7 @@ import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
||||
from nanobot.agent.tools.search import GrepTool
|
||||
from nanobot.agent.tools.search import FindFilesTool, GrepTool
|
||||
from nanobot.agent.tools.web import WebSearchTool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import WebSearchConfig
|
||||
@@ -33,6 +33,68 @@ async def test_web_search_tool_refreshes_dynamic_config_loader(monkeypatch) -> N
|
||||
assert await tool.execute("nanobot") == "duckduckgo:nanobot:3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_files_filters_by_query_glob_and_type(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "settings_view.tsx").write_text("export {}\n", encoding="utf-8")
|
||||
(tmp_path / "src" / "settings_api.py").write_text("pass\n", encoding="utf-8")
|
||||
(tmp_path / "README.md").write_text("settings\n", encoding="utf-8")
|
||||
|
||||
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(
|
||||
path=".",
|
||||
query="settings",
|
||||
glob="src/**",
|
||||
type="ts",
|
||||
)
|
||||
|
||||
assert result.splitlines() == ["src/settings_view.tsx"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_files_can_include_directories(tmp_path: Path) -> None:
|
||||
(tmp_path / "src" / "settings").mkdir(parents=True)
|
||||
(tmp_path / "src" / "settings" / "index.ts").write_text("export {}\n", encoding="utf-8")
|
||||
|
||||
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(path="src", query="settings", include_dirs=True)
|
||||
|
||||
assert "src/settings/" in result.splitlines()
|
||||
assert "src/settings/index.ts" in result.splitlines()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_files_supports_modified_sort_and_pagination(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
for idx, name in enumerate(("a.py", "b.py", "c.py"), start=1):
|
||||
file_path = tmp_path / "src" / name
|
||||
file_path.write_text("pass\n", encoding="utf-8")
|
||||
os.utime(file_path, (idx, idx))
|
||||
|
||||
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(
|
||||
path="src",
|
||||
type="py",
|
||||
sort="modified",
|
||||
head_limit=1,
|
||||
offset=1,
|
||||
)
|
||||
|
||||
assert result.splitlines()[0] == "src/b.py"
|
||||
assert "pagination: limit=1, offset=1" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_files_rejects_paths_outside_workspace(tmp_path: Path) -> None:
|
||||
outside = tmp_path.parent / "outside-find-files.txt"
|
||||
outside.write_text("secret\n", encoding="utf-8")
|
||||
|
||||
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(path=str(outside))
|
||||
|
||||
assert result.startswith("Error:")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_respects_glob_filter_and_context(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
@@ -249,6 +311,7 @@ def test_agent_loop_registers_grep(tmp_path: Path) -> None:
|
||||
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
|
||||
assert "find_files" in loop.tools.tool_names
|
||||
assert "grep" in loop.tools.tool_names
|
||||
|
||||
|
||||
@@ -280,6 +343,7 @@ async def test_subagent_registers_grep(tmp_path: Path) -> None:
|
||||
status = SubagentStatus(task_id="sub-1", label="label", task_description="search task", started_at=time.monotonic())
|
||||
await mgr._run_subagent("sub-1", "search task", "label", {"channel": "cli", "chat_id": "direct"}, status)
|
||||
|
||||
assert "find_files" in captured["tool_names"]
|
||||
assert "grep" in captured["tool_names"]
|
||||
|
||||
|
||||
|
||||
@@ -408,7 +408,8 @@ def test_loader_registers_same_tools_as_old_hardcoded():
|
||||
|
||||
expected = {
|
||||
"read_file", "write_file", "edit_file", "list_dir",
|
||||
"grep", "exec", "web_search", "web_fetch",
|
||||
"find_files", "grep", "exec", "write_stdin", "list_exec_sessions",
|
||||
"web_search", "web_fetch",
|
||||
"message", "spawn", "cron",
|
||||
}
|
||||
actual = set(registered)
|
||||
|
||||
@@ -9,6 +9,7 @@ from nanobot.utils.file_edit_events import (
|
||||
build_file_edit_start_event,
|
||||
line_diff_stats,
|
||||
prepare_file_edit_tracker,
|
||||
prepare_file_edit_trackers,
|
||||
read_file_snapshot,
|
||||
StreamingFileEditTracker,
|
||||
)
|
||||
@@ -81,6 +82,49 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
|
||||
assert (event["added"], event["deleted"]) == (0, 0)
|
||||
|
||||
|
||||
def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
existing = tmp_path / "src" / "existing.py"
|
||||
existing.write_text("old\nkeep\n", encoding="utf-8")
|
||||
delete_me = tmp_path / "src" / "delete_me.py"
|
||||
delete_me.write_text("gone\n", encoding="utf-8")
|
||||
|
||||
patch = """*** Begin Patch
|
||||
*** Add File: src/new.py
|
||||
+fresh
|
||||
*** Update File: src/existing.py
|
||||
@@
|
||||
-old
|
||||
+new
|
||||
keep
|
||||
*** Delete File: src/delete_me.py
|
||||
*** End Patch"""
|
||||
|
||||
trackers = prepare_file_edit_trackers(
|
||||
call_id="call-patch",
|
||||
tool_name="apply_patch",
|
||||
tool=None,
|
||||
workspace=tmp_path,
|
||||
params={"patch": patch},
|
||||
)
|
||||
|
||||
assert [tracker.display_path for tracker in trackers] == [
|
||||
"src/new.py",
|
||||
"src/existing.py",
|
||||
"src/delete_me.py",
|
||||
]
|
||||
|
||||
(tmp_path / "src" / "new.py").write_text("fresh\n", encoding="utf-8")
|
||||
existing.write_text("new\nkeep\n", encoding="utf-8")
|
||||
delete_me.unlink()
|
||||
|
||||
events = [build_file_edit_end_event(tracker, {"patch": patch}) for tracker in trackers]
|
||||
by_path = {event["path"]: event for event in events}
|
||||
assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0)
|
||||
assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1)
|
||||
assert (by_path["src/delete_me.py"]["added"], by_path["src/delete_me.py"]["deleted"]) == (0, 1)
|
||||
|
||||
|
||||
def test_oversized_write_file_end_uses_known_content_for_exact_count(tmp_path: Path) -> None:
|
||||
target = tmp_path / "large.txt"
|
||||
params = {"path": "large.txt", "content": "x" * (2 * 1024 * 1024 + 1)}
|
||||
|
||||
Reference in New Issue
Block a user