feat: add file edit diff progress view
Capture file edit snapshots through runner tool lifecycle hooks and render unified diffs in the WebUI with folding and truncation controls.
This commit is contained in:
@@ -4,7 +4,9 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.hooks import FileEditActivityHook
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.tools.filesystem import EditFileTool, WriteFileTool
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
@@ -80,42 +82,30 @@ async def test_runner_streams_provider_progress_deltas_by_default():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_streams_live_write_file_activity_from_tool_argument_deltas(tmp_path):
|
||||
async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_path):
|
||||
provider = MagicMock()
|
||||
provider.supports_progress_deltas = True
|
||||
call_count = 0
|
||||
progress_events: list[dict] = []
|
||||
(tmp_path / "big.txt").write_text("old\n", encoding="utf-8")
|
||||
|
||||
async def progress_cb(content, *, file_edit_events=None, **kwargs):
|
||||
if file_edit_events:
|
||||
progress_events.extend(file_edit_events)
|
||||
|
||||
tool = WriteFileTool(workspace=tmp_path)
|
||||
|
||||
class Tools:
|
||||
def get_definitions(self):
|
||||
return [{"type": "function", "function": {"name": "write_file"}}]
|
||||
|
||||
def get(self, name):
|
||||
return None
|
||||
def prepare_call(self, name, params):
|
||||
return tool, params, None
|
||||
|
||||
async def execute(self, name, params):
|
||||
assert name == "write_file"
|
||||
assert any(event["approximate"] and event["added"] == 24 for event in progress_events)
|
||||
target = tmp_path / params["path"]
|
||||
target.write_text(params["content"], encoding="utf-8")
|
||||
return "ok"
|
||||
|
||||
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
|
||||
async def chat_stream_with_retry(**kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
assert on_tool_call_delta is not None
|
||||
await on_tool_call_delta({
|
||||
"index": 0,
|
||||
"call_id": "call-write",
|
||||
"name": "write_file",
|
||||
"arguments_delta": '{"path":"big.txt","content":"',
|
||||
})
|
||||
await on_tool_call_delta({"index": 0, "arguments_delta": "line\\n" * 24})
|
||||
return LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
@@ -131,29 +121,37 @@ async def test_runner_streams_live_write_file_activity_from_tool_argument_deltas
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
provider.chat_with_retry = AsyncMock()
|
||||
tools = Tools()
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "write a large file"}],
|
||||
tools=Tools(),
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
progress_callback=progress_cb,
|
||||
workspace=tmp_path,
|
||||
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
assert any(event["approximate"] and event["added"] == 24 for event in progress_events)
|
||||
assert progress_events[0]["phase"] == "start"
|
||||
assert progress_events[0]["added"] == 0
|
||||
assert progress_events[0]["deleted"] == 0
|
||||
assert any(
|
||||
not event["approximate"] and event["phase"] == "end" and event["added"] == 24
|
||||
not event["approximate"]
|
||||
and event["phase"] == "end"
|
||||
and event["added"] == 24
|
||||
and event["deleted"] == 1
|
||||
and event["diff"]["format"] == "unified"
|
||||
for event in progress_events
|
||||
)
|
||||
provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_streams_live_edit_file_activity_from_tool_argument_deltas(tmp_path):
|
||||
async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_path):
|
||||
provider = MagicMock()
|
||||
provider.supports_progress_deltas = True
|
||||
call_count = 0
|
||||
@@ -165,43 +163,19 @@ async def test_runner_streams_live_edit_file_activity_from_tool_argument_deltas(
|
||||
if file_edit_events:
|
||||
progress_events.extend(file_edit_events)
|
||||
|
||||
tool = EditFileTool(workspace=tmp_path)
|
||||
|
||||
class Tools:
|
||||
def get_definitions(self):
|
||||
return [{"type": "function", "function": {"name": "edit_file"}}]
|
||||
|
||||
def get(self, name):
|
||||
return None
|
||||
def prepare_call(self, name, params):
|
||||
return tool, params, None
|
||||
|
||||
async def execute(self, name, params):
|
||||
assert name == "edit_file"
|
||||
assert any(
|
||||
event["tool"] == "edit_file"
|
||||
and event["approximate"]
|
||||
and event["added"] == 3
|
||||
and event["deleted"] == 2
|
||||
for event in progress_events
|
||||
)
|
||||
target.write_text(params["new_text"], encoding="utf-8")
|
||||
return "ok"
|
||||
|
||||
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
|
||||
async def chat_stream_with_retry(**kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
assert on_tool_call_delta is not None
|
||||
await on_tool_call_delta({
|
||||
"index": 0,
|
||||
"call_id": "call-edit",
|
||||
"name": "edit_file",
|
||||
"arguments_delta": (
|
||||
'{"path":"notes.txt","old_text":"old\\nkeep\\n","new_text":"'
|
||||
),
|
||||
})
|
||||
await on_tool_call_delta({
|
||||
"index": 0,
|
||||
"arguments_delta": "new\\nkeep\\nextra\\n",
|
||||
})
|
||||
await on_tool_call_delta({"index": 0, "arguments_delta": '"}'})
|
||||
return LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
@@ -221,75 +195,87 @@ async def test_runner_streams_live_edit_file_activity_from_tool_argument_deltas(
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
provider.chat_with_retry = AsyncMock()
|
||||
tools = Tools()
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "edit a file"}],
|
||||
tools=Tools(),
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
progress_callback=progress_cb,
|
||||
workspace=tmp_path,
|
||||
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
assert any(
|
||||
event["tool"] == "edit_file"
|
||||
and event["approximate"]
|
||||
and event["added"] == 3
|
||||
and event["deleted"] == 2
|
||||
for event in progress_events
|
||||
)
|
||||
assert any(
|
||||
event["tool"] == "edit_file"
|
||||
and not event["approximate"]
|
||||
and event["phase"] == "end"
|
||||
and event["added"] == 2
|
||||
and event["deleted"] == 1
|
||||
and event["diff"]["format"] == "unified"
|
||||
for event in progress_events
|
||||
)
|
||||
provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_marks_unfinished_live_write_file_activity_failed(tmp_path):
|
||||
async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path):
|
||||
provider = MagicMock()
|
||||
provider.supports_progress_deltas = True
|
||||
call_count = 0
|
||||
progress_events: list[dict] = []
|
||||
|
||||
async def progress_cb(content, *, file_edit_events=None, **kwargs):
|
||||
if file_edit_events:
|
||||
progress_events.extend(file_edit_events)
|
||||
|
||||
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
|
||||
assert on_tool_call_delta is not None
|
||||
await on_tool_call_delta({
|
||||
"index": 0,
|
||||
"call_id": "call-write",
|
||||
"name": "write_file",
|
||||
"arguments_delta": '{"path":"aborted.txt","content":"partial\\n',
|
||||
})
|
||||
return LLMResponse(content="stopped", tool_calls=[], finish_reason="stop", usage={})
|
||||
tool = WriteFileTool(workspace=tmp_path)
|
||||
|
||||
class Tools:
|
||||
def get_definitions(self):
|
||||
return [{"type": "function", "function": {"name": "write_file"}}]
|
||||
|
||||
def prepare_call(self, name, params):
|
||||
return tool, params, None
|
||||
|
||||
async def chat_stream_with_retry(**kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call-write",
|
||||
name="write_file",
|
||||
arguments={"path": "aborted.txt"},
|
||||
)
|
||||
],
|
||||
usage={},
|
||||
)
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
provider.chat_with_retry = AsyncMock()
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = [{"type": "function", "function": {"name": "write_file"}}]
|
||||
tools.get.return_value = None
|
||||
tools = Tools()
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "write a large file"}],
|
||||
initial_messages=[{"role": "user", "content": "write a file"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
progress_callback=progress_cb,
|
||||
workspace=tmp_path,
|
||||
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
|
||||
))
|
||||
|
||||
assert result.final_content == "stopped"
|
||||
assert result.stop_reason == "completed"
|
||||
assert progress_events[-1]["path"] == "aborted.txt"
|
||||
assert progress_events[-1]["phase"] == "error"
|
||||
assert progress_events[-1]["status"] == "error"
|
||||
|
||||
Reference in New Issue
Block a user