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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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