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:
chengyongru
2026-07-09 10:42:43 +08:00
committed by Xubin Ren
parent 207813d3b5
commit 7768672c5b
40 changed files with 2224 additions and 1753 deletions
+53
View File
@@ -73,6 +73,9 @@ async def test_composite_fans_out_all_async_methods():
async def emit_reasoning(self, reasoning_content: str | None) -> None:
events.append(f"emit_reasoning:{reasoning_content}")
async def emit_reasoning_end(self) -> None:
events.append("emit_reasoning_end")
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
events.append(f"on_stream:{delta}")
@@ -82,6 +85,15 @@ async def test_composite_fans_out_all_async_methods():
async def before_execute_tools(self, context: AgentHookContext) -> None:
events.append("before_execute_tools")
async def before_execute_tool(self, context, tool_call, tool, params) -> None:
events.append("before_execute_tool")
async def after_execute_tool(self, context, tool_call, tool, params, result) -> None:
events.append("after_execute_tool")
async def on_execute_tool_error(self, context, tool_call, tool, params, error) -> None:
events.append("on_execute_tool_error")
async def after_iteration(self, context: AgentHookContext) -> None:
events.append("after_iteration")
@@ -101,9 +113,13 @@ async def test_composite_fans_out_all_async_methods():
await hook.before_run(run_ctx)
await hook.before_iteration(ctx)
await hook.emit_reasoning("thinking...")
await hook.emit_reasoning_end()
await hook.on_stream(ctx, "hi")
await hook.on_stream_end(ctx, resuming=True)
await hook.before_execute_tools(ctx)
await hook.before_execute_tool(ctx, object(), object(), {})
await hook.after_execute_tool(ctx, object(), object(), {}, "ok")
await hook.on_execute_tool_error(ctx, object(), object(), {}, "err")
await hook.after_iteration(ctx)
await hook.after_run(run_ctx)
await hook.on_error(run_ctx)
@@ -113,9 +129,13 @@ async def test_composite_fans_out_all_async_methods():
"before_run", "before_run",
"before_iteration", "before_iteration",
"emit_reasoning:thinking...", "emit_reasoning:thinking...",
"emit_reasoning_end", "emit_reasoning_end",
"on_stream:hi", "on_stream:hi",
"on_stream_end:True", "on_stream_end:True",
"before_execute_tools", "before_execute_tools",
"before_execute_tool", "before_execute_tool",
"after_execute_tool", "after_execute_tool",
"on_execute_tool_error", "on_execute_tool_error",
"after_iteration", "after_iteration",
"after_run", "after_run",
"on_error", "on_error",
@@ -172,10 +192,20 @@ async def test_composite_error_isolation_all_async():
raise RuntimeError("err")
async def emit_reasoning(self, reasoning_content):
raise RuntimeError("err")
async def emit_reasoning_end(self):
raise RuntimeError("err")
async def on_stream(self, context, delta):
raise RuntimeError("err")
async def on_stream_end(self, context, *, resuming):
raise RuntimeError("err")
async def before_execute_tools(self, context):
raise RuntimeError("err")
async def before_execute_tool(self, context, tool_call, tool, params):
raise RuntimeError("err")
async def after_execute_tool(self, context, tool_call, tool, params, result):
raise RuntimeError("err")
async def on_execute_tool_error(self, context, tool_call, tool, params, error):
raise RuntimeError("err")
async def after_iteration(self, context):
raise RuntimeError("err")
async def after_run(self, context):
@@ -190,10 +220,20 @@ async def test_composite_error_isolation_all_async():
calls.append("before_run")
async def emit_reasoning(self, reasoning_content):
calls.append("emit_reasoning")
async def emit_reasoning_end(self):
calls.append("emit_reasoning_end")
async def on_stream(self, context, delta):
calls.append("on_stream")
async def on_stream_end(self, context, *, resuming):
calls.append("on_stream_end")
async def before_execute_tools(self, context):
calls.append("before_execute_tools")
async def before_execute_tool(self, context, tool_call, tool, params):
calls.append("before_execute_tool")
async def after_execute_tool(self, context, tool_call, tool, params, result):
calls.append("after_execute_tool")
async def on_execute_tool_error(self, context, tool_call, tool, params, error):
calls.append("on_execute_tool_error")
async def after_iteration(self, context):
calls.append("after_iteration")
async def after_run(self, context):
@@ -208,8 +248,13 @@ async def test_composite_error_isolation_all_async():
run_ctx = _run_ctx()
await hook.before_run(run_ctx)
await hook.emit_reasoning("test")
await hook.emit_reasoning_end()
await hook.on_stream(ctx, "delta")
await hook.on_stream_end(ctx, resuming=False)
await hook.before_execute_tools(ctx)
await hook.before_execute_tool(ctx, object(), object(), {})
await hook.after_execute_tool(ctx, object(), object(), {}, "ok")
await hook.on_execute_tool_error(ctx, object(), object(), {}, "err")
await hook.after_iteration(ctx)
await hook.after_run(run_ctx)
await hook.on_error(run_ctx)
@@ -217,8 +262,13 @@ async def test_composite_error_isolation_all_async():
assert calls == [
"before_run",
"emit_reasoning",
"emit_reasoning_end",
"on_stream",
"on_stream_end",
"before_execute_tools",
"before_execute_tool",
"after_execute_tool",
"on_execute_tool_error",
"after_iteration",
"after_run",
"on_error",
@@ -313,6 +363,9 @@ async def test_composite_empty_hooks_no_ops():
await hook.on_stream(ctx, "delta")
await hook.on_stream_end(ctx, resuming=False)
await hook.before_execute_tools(ctx)
await hook.before_execute_tool(ctx, object(), object(), {})
await hook.after_execute_tool(ctx, object(), object(), {}, None)
await hook.on_execute_tool_error(ctx, object(), object(), {}, "err")
await hook.after_iteration(ctx)
await hook.after_run(run_ctx)
await hook.on_error(run_ctx)
+44 -45
View File
@@ -6,8 +6,9 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import nanobot.agent.runner as runner_module
from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.filesystem import WriteFileTool
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
GoalStatusEvent,
@@ -31,7 +32,13 @@ def _make_loop(tmp_path: Path) -> AgentLoop:
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
return AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
return AgentLoop(
bus=bus,
provider=provider,
workspace=tmp_path,
model="test-model",
hook_factories=[create_file_edit_activity_hook],
)
def _attach_webui_runtime_events(loop: AgentLoop, bus: MessageBus) -> None:
@@ -122,15 +129,10 @@ class TestToolEventProgress:
])
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
loop.tools.get_definitions = MagicMock(return_value=[])
tool = WriteFileTool(workspace=tmp_path)
loop.tools.prepare_call = MagicMock(
return_value=(None, {"path": "foo.txt", "content": "new\nextra\n"}, None),
return_value=(tool, {"path": "foo.txt", "content": "new\nextra\n"}, None),
)
async def execute(name: str, params: dict) -> str:
target.write_text(params["content"], encoding="utf-8")
return "ok"
loop.tools.execute = AsyncMock(side_effect=execute)
file_events: list[dict] = []
async def on_progress(
@@ -154,14 +156,15 @@ class TestToolEventProgress:
"path": "foo.txt",
"absolute_path": (tmp_path / "foo.txt").resolve().as_posix(),
"phase": "start",
"added": 2,
"deleted": 1,
"added": 0,
"deleted": 0,
"approximate": True,
"status": "editing",
}
assert file_events[1]["status"] == "done"
assert file_events[1]["approximate"] is False
assert (file_events[1]["added"], file_events[1]["deleted"]) == (2, 1)
assert file_events[1]["diff"]["format"] == "unified"
@pytest.mark.asyncio
async def test_file_edit_snapshot_skipped_when_progress_callback_cannot_emit_file_edits(
@@ -172,6 +175,16 @@ class TestToolEventProgress:
loop = _make_loop(tmp_path)
target = tmp_path / "foo.txt"
target.write_text("old\n", encoding="utf-8")
prepare_file_edit_trackers = MagicMock()
class ObservableWriteTool:
name = "write_file"
async def execute(self, path: str, content: str) -> str:
target.write_text(content, encoding="utf-8")
return "ok"
tool = ObservableWriteTool()
tool_call = ToolCallRequest(
id="call-write",
name="write_file",
@@ -184,17 +197,9 @@ class TestToolEventProgress:
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
loop.tools.get_definitions = MagicMock(return_value=[])
loop.tools.prepare_call = MagicMock(
return_value=(None, {"path": "foo.txt", "content": "new\n"}, None),
return_value=(tool, {"path": "foo.txt", "content": "new\n"}, None),
)
async def execute(name: str, params: dict) -> str:
target.write_text(params["content"], encoding="utf-8")
return "ok"
loop.tools.execute = AsyncMock(side_effect=execute)
prepare_tracker = MagicMock(side_effect=AssertionError("unexpected file snapshot"))
monkeypatch.setattr(runner_module, "prepare_file_edit_tracker", prepare_tracker)
async def on_progress(
content: str,
*,
@@ -203,11 +208,16 @@ class TestToolEventProgress:
) -> None:
pass
monkeypatch.setattr(
"nanobot.agent.hooks.file_edit_activity.prepare_file_edit_trackers",
prepare_file_edit_trackers,
)
final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
assert final_content == "Done"
assert target.read_text(encoding="utf-8") == "new\n"
prepare_tracker.assert_not_called()
prepare_file_edit_trackers.assert_not_called()
@pytest.mark.asyncio
async def test_exec_does_not_emit_file_edit_progress(self, tmp_path: Path) -> None:
@@ -342,31 +352,18 @@ class TestToolEventProgress:
assert outbound.event.file_edit_events == edit_events
@pytest.mark.asyncio
async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None:
async def test_goal_turn_keeps_file_edit_progress_for_webui(self, tmp_path: Path) -> None:
"""The /goal command rewrites the prompt but must not bypass WebUI file-edit progress."""
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "test-model"
call_count = 0
target = tmp_path / "goal.txt"
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-goal-write",
"name": "write_file",
"arguments_delta": '{"path":"goal.txt","content":"',
})
await on_tool_call_delta({
"index": 0,
"arguments_delta": "one\\ntwo\\nthree\\n",
})
await on_tool_call_delta({"index": 0, "arguments_delta": '"}'})
return LLMResponse(
content=None,
tool_calls=[
@@ -383,25 +380,26 @@ class TestToolEventProgress:
)
return LLMResponse(content="Done", tool_calls=[], usage={})
async def execute(name: str, params: dict) -> str:
assert name == "write_file"
target.write_text(params["content"], encoding="utf-8")
return "ok"
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
loop = AgentLoop(
bus=bus,
provider=provider,
workspace=tmp_path,
model="test-model",
hook_factories=[create_file_edit_activity_hook],
)
tool = WriteFileTool(workspace=tmp_path)
loop.tools.get_definitions = MagicMock(return_value=[
{"type": "function", "function": {"name": "write_file"}},
])
loop.tools.prepare_call = MagicMock(
return_value=(
None,
tool,
{"path": "goal.txt", "content": "one\ntwo\nthree\n"},
None,
),
)
loop.tools.execute = AsyncMock(side_effect=execute)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
await loop._dispatch(InboundMessage(
@@ -425,13 +423,14 @@ class TestToolEventProgress:
assert any(
event["status"] == "editing"
and event["approximate"]
and event["added"] == 3
and event["added"] == 0
for event in edit_events
)
assert any(
event["status"] == "done"
and not event["approximate"]
and event["added"] == 3
and event.get("diff", {}).get("format") == "unified"
for event in edit_events
)
provider.chat_with_retry.assert_not_awaited()
+8
View File
@@ -47,6 +47,12 @@ async def test_runner_calls_hooks_in_order():
[tc.name for tc in context.tool_calls],
))
async def before_execute_tool(self, context, tool_call, tool, params) -> None:
events.append(("before_execute_tool", context.iteration, tool_call.name, params))
async def after_execute_tool(self, context, tool_call, tool, params, result) -> None:
events.append(("after_execute_tool", context.iteration, tool_call.name, result))
async def after_iteration(self, context: AgentHookContext) -> None:
events.append((
"after_iteration",
@@ -75,6 +81,8 @@ async def test_runner_calls_hooks_in_order():
assert events == [
("before_iteration", 0),
("before_execute_tools", 0, ["list_dir"]),
("before_execute_tool", 0, "list_dir", {"path": "."}),
("after_execute_tool", 0, "list_dir", "tool result"),
(
"after_iteration",
0,
+62 -76
View File
@@ -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"