feat(diff): implement unified diff formatting for edit_file tool results
This commit is contained in:
@@ -8,6 +8,7 @@ from textual.containers import Vertical
|
||||
from textual.events import Click
|
||||
from textual.widgets import Static
|
||||
|
||||
from ...stream.diff_format import build_edit_diff
|
||||
from ...stream.utils import format_tool_compact
|
||||
|
||||
_SPINNER_FRAMES = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f"
|
||||
@@ -65,6 +66,7 @@ class ToolCallWidget(Vertical):
|
||||
self._tool_id = tool_id
|
||||
self._status = "running"
|
||||
self._result_content = ""
|
||||
self._diff_markup: str | None = None # cached full diff for toggle
|
||||
self._frame = 0
|
||||
self._elapsed = 0.0
|
||||
self._timer_handle = None
|
||||
@@ -186,6 +188,27 @@ class ToolCallWidget(Vertical):
|
||||
output_w = self.query_one(".tool-output", Static)
|
||||
if not self._result_content.strip():
|
||||
return
|
||||
# Diff rendering for edit_file (never truncates — collapses instead)
|
||||
if self._tool_name == "edit_file" and self._status == "success" and self._tool_args:
|
||||
old_str = self._tool_args.get("old_string", "")
|
||||
new_str = self._tool_args.get("new_string", "")
|
||||
path = self._tool_args.get("path", self._tool_args.get("file_path", ""))
|
||||
if old_str and new_str and old_str != new_str:
|
||||
diff_markup = build_edit_diff(path, old_str, new_str)
|
||||
if diff_markup:
|
||||
self._diff_markup = diff_markup
|
||||
if diff_markup.count("\n") > _COLLAPSE_LINES:
|
||||
# Long diff: collapse, click to expand
|
||||
self._collapsed = True
|
||||
output_w.update(
|
||||
Text(" [click to expand diff]", style="dim italic"),
|
||||
)
|
||||
else:
|
||||
# Short diff: show inline
|
||||
self._collapsed = False
|
||||
output_w.update(diff_markup)
|
||||
output_w.add_class("--visible")
|
||||
return
|
||||
if self._status == "error" or not self._should_collapse():
|
||||
# Show full output for errors or short output
|
||||
self._collapsed = False
|
||||
@@ -207,9 +230,23 @@ class ToolCallWidget(Vertical):
|
||||
"""Toggle collapsed output on click."""
|
||||
if self._status == "running" or not self._result_content.strip():
|
||||
return
|
||||
output_w = self.query_one(".tool-output", Static)
|
||||
|
||||
# Diff toggle (separate path — _should_collapse checks plain text,
|
||||
# not the diff markup, so we handle it independently)
|
||||
if self._diff_markup is not None:
|
||||
if self._collapsed:
|
||||
self._collapsed = False
|
||||
output_w.update(self._diff_markup)
|
||||
else:
|
||||
self._collapsed = True
|
||||
output_w.update(
|
||||
Text(" [click to expand diff]", style="dim italic"),
|
||||
)
|
||||
return
|
||||
|
||||
if not self._should_collapse():
|
||||
return # Short output is always visible, nothing to toggle
|
||||
output_w = self.query_one(".tool-output", Static)
|
||||
if self._collapsed:
|
||||
# Expand
|
||||
self._collapsed = False
|
||||
|
||||
@@ -30,6 +30,7 @@ from .utils import (
|
||||
)
|
||||
from .state import SubAgentState, StreamState, _parse_todo_items, _build_todo_stats
|
||||
from .events import stream_agent_events
|
||||
from .diff_format import build_edit_diff, format_diff_rich
|
||||
from .display import (
|
||||
console,
|
||||
formatter,
|
||||
@@ -70,6 +71,9 @@ __all__ = [
|
||||
"_build_todo_stats",
|
||||
# Events
|
||||
"stream_agent_events",
|
||||
# Diff formatting
|
||||
"build_edit_diff",
|
||||
"format_diff_rich",
|
||||
# Display
|
||||
"console",
|
||||
"formatter",
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Diff formatting for edit_file tool results.
|
||||
|
||||
Constructs unified diffs from old_string/new_string tool args and renders
|
||||
them with Rich markup (color-coded lines, line numbers, gutter bars).
|
||||
Works in both Rich CLI and Textual TUI (both render Rich markup natively).
|
||||
|
||||
Ported from upstream deepagents-cli widgets/diff.py + config.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Charset detection (simplified from upstream config.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _detect_unicode_support() -> bool:
|
||||
"""Check if the terminal supports Unicode glyphs."""
|
||||
encoding = getattr(sys.stdout, "encoding", "") or ""
|
||||
if "utf" in encoding.lower():
|
||||
return True
|
||||
lang = os.environ.get("LANG", "") or os.environ.get("LC_ALL", "")
|
||||
return "utf" in lang.lower()
|
||||
|
||||
|
||||
# Module-level glyph constants
|
||||
_UNICODE = _detect_unicode_support()
|
||||
GUTTER_BAR = "\u258c" if _UNICODE else "|" # ▌ or |
|
||||
BOX_VERTICAL = "\u2502" if _UNICODE else "|" # │ or |
|
||||
BOX_DOUBLE_HORIZ = "\u2550" if _UNICODE else "=" # ═ or =
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markup escaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _escape_markup(text: str) -> str:
|
||||
"""Escape Rich markup characters in text.
|
||||
|
||||
Prevents ``[`` and ``]`` from being interpreted as Rich tags.
|
||||
"""
|
||||
return text.replace("[", r"\[").replace("]", r"\]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diff formatting (produces Rich markup string)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_stats_text(additions: int, deletions: int) -> str:
|
||||
"""Build a ``+N -M`` stats string with Rich markup."""
|
||||
parts: list[str] = []
|
||||
if additions:
|
||||
parts.append(f"[green]+{additions}[/green]")
|
||||
if deletions:
|
||||
parts.append(f"[red]-{deletions}[/red]")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def format_diff_rich(
|
||||
diff: str,
|
||||
max_lines: int | None = 100,
|
||||
title: str | None = None,
|
||||
) -> str:
|
||||
"""Format a unified diff with line numbers and colors.
|
||||
|
||||
Args:
|
||||
diff: Unified diff string.
|
||||
max_lines: Maximum number of content lines to show before truncating.
|
||||
``None`` means unlimited.
|
||||
title: Optional title shown above the diff (e.g. file path).
|
||||
|
||||
Returns:
|
||||
Rich-markup formatted diff string.
|
||||
"""
|
||||
if not diff:
|
||||
return "[dim]No changes detected[/dim]"
|
||||
|
||||
lines = diff.splitlines()
|
||||
|
||||
# Compute stats (skip +++ / --- headers)
|
||||
additions = sum(
|
||||
1 for ln in lines if ln.startswith("+") and not ln.startswith("+++")
|
||||
)
|
||||
deletions = sum(
|
||||
1 for ln in lines if ln.startswith("-") and not ln.startswith("---")
|
||||
)
|
||||
|
||||
# Find max line number for column width
|
||||
max_line = 0
|
||||
for line in lines:
|
||||
if m := re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)", line):
|
||||
max_line = max(max_line, int(m.group(1)), int(m.group(2)))
|
||||
width = max(3, len(str(max_line + len(lines))))
|
||||
|
||||
formatted: list[str] = []
|
||||
|
||||
# Title header (═══ title ═══)
|
||||
h = BOX_DOUBLE_HORIZ
|
||||
if title:
|
||||
formatted.append(f"[bold cyan]{h}{h}{h} {_escape_markup(title)} {h}{h}{h}[/bold cyan]")
|
||||
formatted.append("")
|
||||
|
||||
# Stats header
|
||||
stats_text = _build_stats_text(additions, deletions)
|
||||
if stats_text:
|
||||
formatted.extend([stats_text, ""])
|
||||
|
||||
old_num = new_num = 0
|
||||
line_count = 0
|
||||
|
||||
for line in lines:
|
||||
if max_lines is not None and line_count >= max_lines:
|
||||
formatted.append(
|
||||
f"\n[dim]... ({len(lines) - line_count} more lines)[/dim]"
|
||||
)
|
||||
break
|
||||
|
||||
# Skip file headers
|
||||
if line.startswith(("---", "+++")):
|
||||
continue
|
||||
|
||||
# Hunk headers — update line numbers, don't display
|
||||
if m := re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)", line):
|
||||
old_num, new_num = int(m.group(1)), int(m.group(2))
|
||||
continue
|
||||
|
||||
content = line[1:] if line else ""
|
||||
escaped = _escape_markup(content)
|
||||
|
||||
if line.startswith("-"):
|
||||
gutter = f"[red bold]{GUTTER_BAR}[/red bold]"
|
||||
ln = f"[dim]{old_num:>{width}}[/dim]"
|
||||
body = f"[on #2d1515]{escaped}[/on #2d1515]"
|
||||
formatted.append(f"{gutter}{ln} {body}")
|
||||
old_num += 1
|
||||
line_count += 1
|
||||
elif line.startswith("+"):
|
||||
gutter = f"[green bold]{GUTTER_BAR}[/green bold]"
|
||||
ln = f"[dim]{new_num:>{width}}[/dim]"
|
||||
body = f"[on #152d15]{escaped}[/on #152d15]"
|
||||
formatted.append(f"{gutter}{ln} {body}")
|
||||
new_num += 1
|
||||
line_count += 1
|
||||
elif line.startswith(" "):
|
||||
formatted.append(
|
||||
f"[dim]{BOX_VERTICAL}{old_num:>{width}}[/dim] {escaped}"
|
||||
)
|
||||
old_num += 1
|
||||
new_num += 1
|
||||
line_count += 1
|
||||
elif line.strip() == "...":
|
||||
formatted.append("[dim]...[/dim]")
|
||||
line_count += 1
|
||||
|
||||
# Stats footer (matches upstream EnhancedDiff)
|
||||
if stats_text:
|
||||
formatted.extend(["", stats_text])
|
||||
|
||||
return "\n".join(formatted)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# High-level helper: build diff from edit_file tool args
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_edit_diff(
|
||||
file_path: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
max_lines: int | None = None,
|
||||
) -> str | None:
|
||||
"""Construct a formatted diff from edit_file tool arguments.
|
||||
|
||||
Args:
|
||||
file_path: Path shown in the diff header.
|
||||
old_string: Original text that was replaced.
|
||||
new_string: Replacement text.
|
||||
max_lines: Max content lines before truncation. ``None`` (default)
|
||||
means no truncation — callers handle collapsing if needed.
|
||||
|
||||
Returns:
|
||||
Rich-markup formatted diff, or ``None`` if inputs are equal/empty.
|
||||
"""
|
||||
if old_string == new_string:
|
||||
return None
|
||||
if not old_string and not new_string:
|
||||
return None
|
||||
|
||||
diff_lines = list(difflib.unified_diff(
|
||||
old_string.splitlines(),
|
||||
new_string.splitlines(),
|
||||
fromfile=file_path,
|
||||
tofile=file_path,
|
||||
lineterm="",
|
||||
n=3,
|
||||
))
|
||||
|
||||
if not diff_lines:
|
||||
return None
|
||||
|
||||
diff_text = "\n".join(diff_lines)
|
||||
return format_diff_rich(diff_text, max_lines=max_lines, title=file_path)
|
||||
@@ -22,6 +22,7 @@ from ..paths import resolve_virtual_path
|
||||
from .formatter import ToolResultFormatter
|
||||
from .state import StreamState, SubAgentState, _build_todo_stats, _parse_todo_items, _INTERNAL_TOOLS
|
||||
from .utils import DisplayLimits, ToolStatus, format_tool_compact, is_success
|
||||
from .diff_format import build_edit_diff
|
||||
from .events import stream_agent_events
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -76,10 +77,16 @@ def _format_single_todo(item: dict) -> Text:
|
||||
# Tool result formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def format_tool_result_compact(_name: str, content: str, max_lines: int = 5) -> list:
|
||||
def format_tool_result_compact(
|
||||
_name: str,
|
||||
content: str,
|
||||
max_lines: int = 5,
|
||||
tool_args: dict | None = None,
|
||||
) -> list:
|
||||
"""Format tool result as tree output.
|
||||
|
||||
Special handling for write_todos: shows formatted checklist with status symbols.
|
||||
Special handling for edit_file: shows color-coded unified diff.
|
||||
"""
|
||||
elements = []
|
||||
|
||||
@@ -87,6 +94,17 @@ def format_tool_result_compact(_name: str, content: str, max_lines: int = 5) ->
|
||||
elements.append(Text(" \u2514 (empty)", style="dim"))
|
||||
return elements
|
||||
|
||||
# Special handling for edit_file: show diff
|
||||
if _name == "edit_file" and tool_args and is_success(content):
|
||||
old_str = tool_args.get("old_string", "")
|
||||
new_str = tool_args.get("new_string", "")
|
||||
path = tool_args.get("path", tool_args.get("file_path", ""))
|
||||
if old_str and new_str and old_str != new_str:
|
||||
diff_markup = build_edit_diff(path, old_str, new_str)
|
||||
if diff_markup:
|
||||
elements.append(Text.from_markup(diff_markup))
|
||||
return elements
|
||||
|
||||
# Special handling for write_todos
|
||||
if _name == "write_todos":
|
||||
items = _parse_todo_items(content)
|
||||
@@ -431,9 +449,10 @@ def create_streaming_display(
|
||||
for tc, tr in completed_regular:
|
||||
elements.append(_render_tool_call_line(tc, tr))
|
||||
content = tr.get('content', '') if tr else ''
|
||||
if tr and not is_success(content):
|
||||
if tr and (not is_success(content) or tc.get('name') == 'edit_file'):
|
||||
result_elements = format_tool_result_compact(
|
||||
tr['name'], content, max_lines=10,
|
||||
tool_args=tc.get('args'),
|
||||
)
|
||||
elements.extend(result_elements)
|
||||
|
||||
@@ -475,9 +494,10 @@ def create_streaming_display(
|
||||
for tc, tr in visible:
|
||||
elements.append(_render_tool_call_line(tc, tr))
|
||||
content = tr.get('content', '') if tr else ''
|
||||
if tr and not is_success(content):
|
||||
if tr and (not is_success(content) or tc.get('name') == 'edit_file'):
|
||||
result_elements = format_tool_result_compact(
|
||||
tr['name'], content, max_lines=5,
|
||||
tool_args=tc.get('args'),
|
||||
)
|
||||
elements.extend(result_elements)
|
||||
|
||||
@@ -638,6 +658,7 @@ def display_final_results(
|
||||
tr['name'],
|
||||
content,
|
||||
max_lines=10,
|
||||
tool_args=tc.get('args'),
|
||||
)
|
||||
for elem in result_elements:
|
||||
console.print(elem)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Tests for EvoScientist.stream.diff_format module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
from unittest import mock
|
||||
|
||||
from EvoScientist.stream.diff_format import (
|
||||
_detect_unicode_support,
|
||||
_escape_markup,
|
||||
build_edit_diff,
|
||||
format_diff_rich,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _escape_markup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEscapeMarkup:
|
||||
def test_escapes_brackets(self):
|
||||
assert _escape_markup("[bold]text[/bold]") == r"\[bold\]text\[/bold\]"
|
||||
|
||||
def test_plain_text_unchanged(self):
|
||||
assert _escape_markup("hello world") == "hello world"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _escape_markup("") == ""
|
||||
|
||||
def test_nested_brackets(self):
|
||||
assert _escape_markup("a[b[c]]d") == r"a\[b\[c\]\]d"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _detect_unicode_support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDetectUnicodeSupport:
|
||||
def test_utf8_encoding(self):
|
||||
with mock.patch("sys.stdout") as mock_stdout:
|
||||
mock_stdout.encoding = "utf-8"
|
||||
assert _detect_unicode_support() is True
|
||||
|
||||
def test_ascii_encoding_with_utf_lang(self):
|
||||
with mock.patch("sys.stdout") as mock_stdout:
|
||||
mock_stdout.encoding = "ascii"
|
||||
with mock.patch.dict("os.environ", {"LANG": "en_US.UTF-8", "LC_ALL": ""}):
|
||||
assert _detect_unicode_support() is True
|
||||
|
||||
def test_ascii_encoding_no_utf_lang(self):
|
||||
with mock.patch("sys.stdout") as mock_stdout:
|
||||
mock_stdout.encoding = "ascii"
|
||||
with mock.patch.dict(
|
||||
"os.environ", {"LANG": "C", "LC_ALL": ""}, clear=False
|
||||
):
|
||||
assert _detect_unicode_support() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_diff_rich
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFormatDiffRich:
|
||||
def test_empty_diff_returns_dim_message(self):
|
||||
result = format_diff_rich("")
|
||||
assert "No changes detected" in result
|
||||
assert "[dim]" in result
|
||||
|
||||
def test_single_line_change(self):
|
||||
old = ["hello"]
|
||||
new = ["world"]
|
||||
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=3))
|
||||
result = format_diff_rich(diff)
|
||||
# Should contain addition and deletion markers
|
||||
assert "[green]+1[/green]" in result
|
||||
assert "[red]-1[/red]" in result
|
||||
# Deletion line (red background)
|
||||
assert "2d1515" in result # red background color
|
||||
# Addition line (green background)
|
||||
assert "152d15" in result # green background color
|
||||
|
||||
def test_stats_header(self):
|
||||
old = ["a", "b", "c"]
|
||||
new = ["a", "x", "c", "d"]
|
||||
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=3))
|
||||
result = format_diff_rich(diff)
|
||||
# 1 deletion (b), 2 additions (x, d)
|
||||
assert "[green]+2[/green]" in result
|
||||
assert "[red]-1[/red]" in result
|
||||
|
||||
def test_max_lines_truncation(self):
|
||||
old = [f"line{i}" for i in range(50)]
|
||||
new = [f"LINE{i}" for i in range(50)]
|
||||
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=0))
|
||||
result = format_diff_rich(diff, max_lines=10)
|
||||
assert "more lines" in result
|
||||
|
||||
def test_context_lines_dimmed(self):
|
||||
old = ["a", "b", "c"]
|
||||
new = ["a", "B", "c"]
|
||||
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=3))
|
||||
result = format_diff_rich(diff)
|
||||
# Context lines should use dim styling
|
||||
assert "[dim]" in result
|
||||
|
||||
def test_title_header(self):
|
||||
old = ["hello"]
|
||||
new = ["world"]
|
||||
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=3))
|
||||
result = format_diff_rich(diff, title="/path/to/file.py")
|
||||
# Title should appear with bold cyan and box-drawing chars
|
||||
assert "file.py" in result
|
||||
assert "[bold cyan]" in result
|
||||
|
||||
def test_title_none_omits_header(self):
|
||||
old = ["hello"]
|
||||
new = ["world"]
|
||||
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=3))
|
||||
result = format_diff_rich(diff, title=None)
|
||||
assert "[bold cyan]" not in result
|
||||
|
||||
def test_stats_footer(self):
|
||||
old = ["a"]
|
||||
new = ["b"]
|
||||
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=3))
|
||||
result = format_diff_rich(diff)
|
||||
# Stats should appear both at top and bottom
|
||||
lines = result.splitlines()
|
||||
# First non-empty line is stats header, last non-empty line is stats footer
|
||||
non_empty = [ln for ln in lines if ln.strip()]
|
||||
assert non_empty[0] == non_empty[-1] # header == footer
|
||||
|
||||
def test_max_lines_none_unlimited(self):
|
||||
old = [f"line{i}" for i in range(200)]
|
||||
new = [f"LINE{i}" for i in range(200)]
|
||||
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=0))
|
||||
result = format_diff_rich(diff, max_lines=None)
|
||||
# Should NOT have truncation marker
|
||||
assert "more lines" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_edit_diff
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildEditDiff:
|
||||
def test_returns_none_when_equal(self):
|
||||
assert build_edit_diff("/foo.py", "same", "same") is None
|
||||
|
||||
def test_returns_none_when_both_empty(self):
|
||||
assert build_edit_diff("/foo.py", "", "") is None
|
||||
|
||||
def test_returns_formatted_markup_for_valid_diff(self):
|
||||
result = build_edit_diff(
|
||||
"/foo.py",
|
||||
"old line",
|
||||
"new line",
|
||||
)
|
||||
assert result is not None
|
||||
assert "[green]" in result
|
||||
assert "[red]" in result
|
||||
|
||||
def test_multiline_diff(self):
|
||||
old = "line1\nline2\nline3"
|
||||
new = "line1\nmodified\nline3\nline4"
|
||||
result = build_edit_diff("/test.py", old, new)
|
||||
assert result is not None
|
||||
assert "+2" in result # 2 additions
|
||||
assert "-1" in result # 1 deletion
|
||||
|
||||
def test_no_truncation_by_default(self):
|
||||
old = "\n".join(f"old{i}" for i in range(100))
|
||||
new = "\n".join(f"new{i}" for i in range(100))
|
||||
result = build_edit_diff("/big.py", old, new)
|
||||
assert result is not None
|
||||
# Default max_lines=None means no truncation
|
||||
assert "more lines" not in result
|
||||
|
||||
def test_explicit_max_lines_truncates(self):
|
||||
old = "\n".join(f"old{i}" for i in range(100))
|
||||
new = "\n".join(f"new{i}" for i in range(100))
|
||||
result = build_edit_diff("/big.py", old, new, max_lines=5)
|
||||
assert result is not None
|
||||
assert "more lines" in result
|
||||
|
||||
def test_file_path_shown_as_title(self):
|
||||
result = build_edit_diff("/my/file.py", "a", "b")
|
||||
assert result is not None
|
||||
# File path should appear as the title header
|
||||
assert "file.py" in result
|
||||
assert "[bold cyan]" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration with format_tool_result_compact
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFormatToolResultCompactEditFile:
|
||||
def test_edit_file_with_tool_args_shows_diff(self):
|
||||
from EvoScientist.stream.display import format_tool_result_compact
|
||||
|
||||
result = format_tool_result_compact(
|
||||
"edit_file",
|
||||
"[OK] Successfully replaced 1 instance(s)",
|
||||
tool_args={
|
||||
"path": "/foo.py",
|
||||
"old_string": "hello",
|
||||
"new_string": "world",
|
||||
},
|
||||
)
|
||||
# Should return markup elements with diff content
|
||||
assert len(result) >= 1
|
||||
plain = result[0].plain if hasattr(result[0], "plain") else str(result[0])
|
||||
assert "+1" in plain or "world" in plain or "hello" in plain
|
||||
|
||||
def test_edit_file_without_tool_args_falls_through(self):
|
||||
from EvoScientist.stream.display import format_tool_result_compact
|
||||
|
||||
result = format_tool_result_compact(
|
||||
"edit_file",
|
||||
"[OK] Successfully replaced 1 instance(s)",
|
||||
)
|
||||
# Without tool_args, falls through to normal rendering
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_edit_file_error_shows_error_not_diff(self):
|
||||
from EvoScientist.stream.display import format_tool_result_compact
|
||||
|
||||
result = format_tool_result_compact(
|
||||
"edit_file",
|
||||
"[ERROR] File not found",
|
||||
tool_args={
|
||||
"path": "/foo.py",
|
||||
"old_string": "hello",
|
||||
"new_string": "world",
|
||||
},
|
||||
)
|
||||
# Error content should show error, not diff
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_backward_compatible_no_tool_args(self):
|
||||
from EvoScientist.stream.display import format_tool_result_compact
|
||||
|
||||
# Existing calls without tool_args should still work
|
||||
result = format_tool_result_compact("read_file", "[OK] 42 lines")
|
||||
assert len(result) >= 1
|
||||
Reference in New Issue
Block a user