diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index a25d713b80a..24075884acd 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -219,6 +219,44 @@ def test_tui_verbose_tool_events_omit_details_when_redaction_fails(monkeypatch): assert "result_text" not in events[1][2] +def test_tool_complete_emits_full_unified_diff(monkeypatch): + events: list[tuple[str, str, dict]] = [] + monkeypatch.setattr( + server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload)) + ) + monkeypatch.setitem( + server._sessions, + "diff-test", + {"tool_progress_mode": "concise", "tool_started_at": {}, "edit_snapshots": {}}, + ) + + diff = "--- a/x.py\n+++ b/x.py\n@@ -1 +1 @@\n-a = 1\n+a = 2\n" + result = json.dumps({"success": True, "diff": diff}) + server._on_tool_complete("diff-test", "tool-1", "patch", {"mode": "replace", "path": "x.py"}, result) + + assert events and events[0][0] == "tool.complete" + payload = events[0][2] + # the raw unified diff rides alongside the pretty/capped inline_diff + assert payload["diff_unified"] == diff + assert "inline_diff" in payload + + +def test_cap_diff_unified_truncates_at_line_boundary(): + line = "+" + "x" * 63 # 64 bytes per line incl. newline + diff = "\n".join([line] * 100) + + capped = server._cap_diff_unified(diff, max_bytes=1000) + + body, _, marker = capped.rpartition("\n") + assert marker.startswith("# … diff truncated (") + assert marker.endswith(" more bytes)") + assert len(body.encode("utf-8")) <= 1000 + # cut on a line boundary: every surviving line is intact + assert all(l == line for l in body.split("\n")) + # under the cap → untouched + assert server._cap_diff_unified("small", max_bytes=1000) == "small" + + def test_dispatch_rejects_non_object_request(): resp = server.dispatch([]) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 018d2b8b9f4..a67dbe5bfb4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1950,6 +1950,28 @@ def _cap_tui_verbose_text(text: str) -> str: return f"{label}{tail}" +# The FULL raw unified diff shipped on file-edit tool.complete (`diff_unified`, +# for clients with a native diff renderer — ui-opentui). Unlike the verbose +# trail above this is rendered COLLAPSED by default and only on edit tools, so +# it gets a far larger budget; 512KB is still a hard ceiling so a runaway +# multi-megabyte edit can't flood the pipe. +_DIFF_UNIFIED_MAX_BYTES = 512 * 1024 + + +def _cap_diff_unified(diff: str, max_bytes: int = _DIFF_UNIFIED_MAX_BYTES) -> str: + raw = diff.encode("utf-8") + if len(raw) <= max_bytes: + return diff + head = raw[:max_bytes].decode("utf-8", errors="ignore") + # Truncate at a line boundary so the surviving diff stays parseable, then + # append an honest marker line (never send more than the cap + marker). + cut = head.rfind("\n") + if cut > 0: + head = head[:cut] + omitted = len(raw) - len(head.encode("utf-8")) + return f"{head}\n# … diff truncated ({omitted} more bytes)" + + def _redact_tui_verbose_text(text: str) -> str: try: from agent.redact import redact_sensitive_text @@ -2096,7 +2118,23 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result payload["inline_diff"] = "\n".join(rendered) except Exception: pass - if _tool_progress_enabled(sid) or payload.get("inline_diff"): + # Alongside the pretty-rendered/capped `inline_diff` (Ink consumes that), ship + # the RAW unified diff for clients with a native diff renderer (ui-opentui's + # file-tool view). Capped at _DIFF_UNIFIED_MAX_BYTES with an honest marker. + try: + from agent.display import extract_edit_diff + + diff_unified = extract_edit_diff( + name, + result, + function_args=args, + snapshot=snapshot, + ) + if diff_unified: + payload["diff_unified"] = _cap_diff_unified(diff_unified) + except Exception: + pass + if _tool_progress_enabled(sid) or payload.get("inline_diff") or payload.get("diff_unified"): _emit("tool.complete", sid, payload)