fix(code): harden approval content rendering (#4581)

Closes #4575

Defend against crashes where malformed streamed file-edit tool arguments
could break the manual approval screen before the tool call was
validated.

---

The manual approval UI renders tool-call arguments before the filesystem
tool schema has a chance to validate them. If a streamed `write_file` or
`edit_file` call contains malformed non-string content, the approval
widgets could call string methods on raw objects and crash the TUI.

This change makes approval rendering defensive by formatting arbitrary
raw content into displayable text before line counting, diff generation,
or Markdown rendering. Valid string content still passes through
unchanged; malformed objects are JSON-formatted when possible and fall
back to `str()` otherwise.
This commit is contained in:
Mason Daugherty
2026-07-08 22:05:31 -04:00
committed by GitHub
parent 58db2b78f4
commit 38446fda64
3 changed files with 84 additions and 6 deletions
@@ -10,6 +10,7 @@ from deepagents_code.tui.widgets.tool_widgets import (
EditFileApprovalWidget,
GenericApprovalWidget,
WriteFileApprovalWidget,
format_display_content,
)
if TYPE_CHECKING:
@@ -52,7 +53,7 @@ class WriteFileRenderer(ToolRenderer):
) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
# Extract file extension for syntax highlighting
file_path = tool_args.get("file_path", "")
content = tool_args.get("content", "")
content = format_display_content(tool_args.get("content", ""))
# Get file extension
file_extension = "text"
@@ -121,8 +122,8 @@ class EditFileRenderer(ToolRenderer):
assistant_id: str | None = None, # noqa: ARG004
) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
file_path = tool_args.get("file_path", "")
old_string = tool_args.get("old_string", "")
new_string = tool_args.get("new_string", "")
old_string = format_display_content(tool_args.get("old_string", ""))
new_string = format_display_content(tool_args.get("new_string", ""))
# Generate unified diff
diff_lines = EditFileRenderer._generate_diff(old_string, new_string)
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from textual.containers import Vertical
@@ -20,6 +21,23 @@ _MAX_DIFF_LINES = 50
_MAX_PREVIEW_LINES = 20
def format_display_content(content: object) -> str:
"""Coerce arbitrary tool-arg content into a displayable string.
Strings pass through unchanged; other values are JSON-formatted for
readability, falling back to `str()` when serialization fails.
Returns:
A string safe to render in an approval widget.
"""
if isinstance(content, str):
return content
try:
return json.dumps(content, ensure_ascii=False, indent=2)
except (TypeError, ValueError, RecursionError):
return str(content)
def _format_stats(additions: int, deletions: int) -> Content:
"""Format addition/deletion stats as styled Content.
@@ -142,7 +160,7 @@ class WriteFileApprovalWidget(ToolApprovalWidget):
Widgets displaying file path header and syntax-highlighted content.
"""
file_path = self.data.get("file_path", "")
content = self.data.get("content", "")
content = format_display_content(self.data.get("content", ""))
file_extension = self.data.get("file_extension", "text")
# Content with syntax highlighting via Markdown code block
@@ -175,8 +193,8 @@ class EditFileApprovalWidget(ToolApprovalWidget):
"""
file_path = self.data.get("file_path", "")
diff_lines = self.data.get("diff_lines", [])
old_string = self.data.get("old_string", "")
new_string = self.data.get("new_string", "")
old_string = format_display_content(self.data.get("old_string", ""))
new_string = format_display_content(self.data.get("new_string", ""))
additions, deletions = _count_diff_stats(diff_lines, old_string, new_string)
yield from _file_header(file_path, additions, deletions)
@@ -4,9 +4,68 @@ from deepagents_code.tui.widgets.tool_renderers import get_renderer
from deepagents_code.tui.widgets.tool_widgets import (
EditFileApprovalWidget,
GenericApprovalWidget,
WriteFileApprovalWidget,
)
def test_write_renderer_formats_non_string_content() -> None:
widget_class, data = get_renderer("write_file").get_approval_widget(
{"file_path": "data.json", "content": {"a": "b"}}
)
assert widget_class is WriteFileApprovalWidget
assert data["content"] == '{\n "a": "b"\n}'
def test_write_renderer_falls_back_to_str_for_unserializable_content() -> None:
widget_class, data = get_renderer("write_file").get_approval_widget(
{"file_path": "data.txt", "content": {1, 2, 3}}
)
assert widget_class is WriteFileApprovalWidget
assert data["content"] == str({1, 2, 3})
def test_write_widget_formats_non_string_content() -> None:
widgets = list(
WriteFileApprovalWidget(
{"file_path": "data.json", "content": {"a": "b"}}
).compose()
)
assert len(widgets) == 3
def test_edit_renderer_formats_non_string_content() -> None:
widget_class, data = get_renderer("edit_file").get_approval_widget(
{
"file_path": "data.json",
"old_string": {"a": "b"},
"new_string": {"a": "c"},
}
)
assert widget_class is EditFileApprovalWidget
assert data["old_string"] == '{\n "a": "b"\n}'
assert data["new_string"] == '{\n "a": "c"\n}'
assert '- "a": "b"' in data["diff_lines"]
assert '+ "a": "c"' in data["diff_lines"]
def test_edit_widget_formats_non_string_content() -> None:
widgets = list(
EditFileApprovalWidget(
{
"file_path": "data.json",
"old_string": {"a": "b"},
"new_string": {"a": "c"},
}
).compose()
)
assert widgets
def test_delete_renderer_shows_removed_file_diff(tmp_path: Path) -> None:
target = tmp_path / "old.txt"
target.write_text("alpha\nbeta\n", encoding="utf-8")