fix(code): support unambiguous read_file gutters (#4711)

Depends on #4561.

Deep Agents Code now displays both legacy and current `read_file` line
gutters without dropping tab-based source indentation.

---

Deep Agents Code compacts `read_file` line-number gutters before
displaying tool output. The SDK change in #4561 replaces the ambiguous
tab separator with two spaces, but the TUI parser currently recognizes
only the legacy format.

Teach the parser to recognize both formats while consuming only the
gutter separator. This preserves tabs that belong to source indentation
and keeps compatibility with older string backends that still emit `cat
-n`-style output.
This commit is contained in:
Mason Daugherty
2026-07-14 00:01:46 -04:00
committed by GitHub
parent cf057b4bcd
commit 2089b54e2a
2 changed files with 155 additions and 41 deletions
@@ -158,6 +158,19 @@ _SUCCESS_EXIT_RE = re.compile(r"\n?\[Command succeeded with exit code 0\]\s*$")
"""Strip the SDK's `[Command succeeded with exit code 0]` trailer from tool output."""
_READ_FILE_GUTTER_RE = re.compile(r"^ *(\d+(?:\.\d+)?)(?: |\t)(.*)$")
"""Match a `read_file` gutter row into (marker, source).
The marker is a bare `N` or `N.M` (the latter a wrapped-line continuation) —
both sides of the dot required, so a stray `.5` head is not a gutter. The
separator is exactly two spaces (current format) or a single tab (legacy
`cat -n`). Only the separator is consumed and leading padding is spaces-only, so
source indentation — including leading tabs — after the gutter stays put. Kept in
sync with the separator emitted by deepagents' `format_content_with_line_numbers`
(the authoritative producer). See `ToolCallMessage._compact_line_gutter`.
"""
def _strip_success_exit_line(text: str) -> str:
"""Remove the `[Command succeeded with exit code 0]` trailer.
@@ -1983,48 +1996,49 @@ class ToolCallMessage(Vertical):
@staticmethod
def _compact_line_gutter(output: str) -> str:
r"""Tighten `read_file`'s cat -n line-number gutter for display.
r"""Tighten `read_file`'s line-number gutter for display.
The tool emits `f"{line_num:6d}\t{line}"` — a 6-wide right-justified
number plus a tab — so even single-digit line numbers carry five
leading spaces and the tab pushes content to a distant tab stop. The
model needs that raw format for edits, but the TUI renders a compact
gutter instead: numbers right-justified to the widest number actually
present, then two spaces, mirroring how grep/glob results sit flush
left. Source indentation after the gutter is preserved untouched.
`read_file` prefixes each row with a right-justified line marker — `N`,
or `N.M` for a wrapped-line continuation — then two spaces, then the
original source content. (Output from deepagents versions predating the
gutter disambiguation in #4561 used the older `cat -n` gutter — a wide
right-justified number and a tab — which may still surface from cached or
persisted transcripts.) The model needs the raw gutter for edits, but the
TUI re-justifies markers to the widest marker actually present, then two
spaces, mirroring how grep/glob results sit flush left. Source
indentation after the gutter is preserved untouched.
Lines that don't match the cat -n shape (e.g. test fixtures or
non-numbered output) are passed through unchanged.
The gutter shape is `_READ_FILE_GUTTER_RE`. Lines that don't match a
gutter shape (e.g. test fixtures or non-numbered output) are passed
through unchanged.
Returns:
The output with compacted gutters, or the original string if no
line-numbered content was found.
"""
lines = output.split("\n")
# Split each line on its gutter tab into (number, source). The gutter
# tab is always the first one; any tabs in `text` are real source
# indentation and stay put. The head must be a bare `N` or `N.M` (the
# latter is a wrapped-line continuation marker) — both sides of the dot
# are required, so a stray `.5` head marks a non-gutter line.
parsed: list[tuple[str, str] | None] = []
width = 0
for line in lines:
head, tab, text = line.partition("\t")
num = head.strip()
whole, dot, frac = num.partition(".")
if tab and whole.isdigit() and (not dot or frac.isdigit()):
parsed.append((num, text))
width = max(width, len(num))
match = _READ_FILE_GUTTER_RE.match(line)
if match:
marker, source = match.groups()
parsed.append((marker, source))
width = max(width, len(marker))
else:
parsed.append(None)
if width == 0:
return output
return "\n".join(
f"{row[0]:>{width}} {row[1]}" if row else line
for line, row in zip(lines, parsed, strict=True)
)
compacted: list[str] = []
for line, row in zip(lines, parsed, strict=True):
if row is None:
compacted.append(line)
else:
marker, source = row
compacted.append(f"{marker:>{width}} {source}")
return "\n".join(compacted)
def _format_edit_file_output(
self, output: str, *, is_preview: bool = False
@@ -6,6 +6,10 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from deepagents.backends.utils import (
MAX_LINE_LENGTH,
format_content_with_line_numbers,
)
from rich.style import Style
from textual.app import App, ComposeResult
from textual.content import Content
@@ -2794,14 +2798,15 @@ class TestToolCallMessageFileOutput:
assert result.truncation == "2 more lines"
def test_format_output_compacts_line_number_gutter(self) -> None:
r"""Line-number gutters are tightened, all rows aligned to one column.
def test_format_output_compacts_legacy_cat_n_gutter(self) -> None:
r"""Legacy `cat -n` gutters are tightened, all rows aligned to one column.
`read_file` emits `f"{line_num:6d}\t{line}"` — a 6-wide right-justified
number plus a tab — which renders far from the line numbers and (when
the first row's padding was stripped) misaligned. The TUI recomputes a
compact gutter: numbers right-justified to the widest number present,
two spaces, then the original source indentation.
deepagents versions predating #4561 emitted `f"{line_num:6d}\t{line}"` —
a 6-wide right-justified number plus a tab — which renders far from the
line numbers and (when the first row's padding was stripped) misaligned.
Such output may still surface from cached or persisted transcripts. The
TUI recomputes a compact gutter: numbers right-justified to the widest
number present, two spaces, then the original source indentation.
"""
msg = ToolCallMessage("read_file", {"path": "/tmp/a.py"})
# cat -n style: 6-wide right-justified number + tab + source line.
@@ -2812,6 +2817,23 @@ class TestToolCallMessageFileOutput:
# indentation (the 4 spaces on line 3) preserved verbatim.
assert result.content.plain == '1 """doc"""\n2 \n3 indented'
def test_format_output_leaves_current_two_space_gutter_intact(self) -> None:
r"""The current SDK gutter is already compact, so compaction is a no-op.
`read_file` now emits `f"{marker:>width} {line}"` — a right-justified
marker, two spaces, then source. Re-justifying to the widest marker
reproduces the same string, and crucially a tab-indented source line
keeps its leading tab (the two-space separator, not the source tab, is
consumed). Regression guard: the old tab-splitting gutter dropped that
indentation, re-introducing the ambiguity this format fixes.
"""
msg = ToolCallMessage("read_file", {"path": "/tmp/a.py"})
# Two-digit max marker => width 2; line 2 is tab-indented source.
output = " 9 def build_config():\n10 \treturn {}"
result = msg._format_output(output, is_preview=False)
assert result.content.plain == " 9 def build_config():\n10 \treturn {}"
def test_compact_line_gutter_right_justifies_to_widest_number(self) -> None:
r"""Multi-digit line numbers set a uniform, right-justified gutter."""
# Lines 9 and 10: single- vs double-digit numbers must align right.
@@ -2832,30 +2854,108 @@ class TestToolCallMessageFileOutput:
assert compacted == " 1 first\n1.1 wrapped"
def test_compact_line_gutter_preserves_source_tabs(self) -> None:
r"""Only the first (gutter) tab is consumed; source tabs stay put.
def test_compact_line_gutter_preserves_source_tabs_legacy(self) -> None:
r"""Only the gutter tab is consumed; a legacy row's source tab stays put.
Tab-indented source means a tab immediately after the gutter tab.
`partition` splits on the first tab only, so the source tab survives.
Tab-indented source means a tab immediately after the gutter tab. The
gutter regex consumes only the separator, so the source tab survives.
"""
output = " 1\t\tdef foo():"
compacted = ToolCallMessage._compact_line_gutter(output)
assert compacted == "1 \tdef foo():"
def test_compact_line_gutter_preserves_source_tabs_current(self) -> None:
r"""A current-format row's leading source tab (and a blank row) survive.
The current gutter separator is two spaces, so a tab-indented source
line reads as `"N \tsource"`. Only the two spaces are consumed; the
source tab must remain. This is the regression guard for the tab-
splitting gutter that used to drop it. A blank source line (`"N "` —
marker, separator, empty source) round-trips unchanged too; the
`_compact_line_gutter` return preserves its trailing separator (the
display path may later strip trailing space, but the compactor does not).
"""
output = "1 def foo():\n2 \treturn 1\n3 "
compacted = ToolCallMessage._compact_line_gutter(output)
assert compacted == "1 def foo():\n2 \treturn 1\n3 "
def test_compact_line_gutter_parses_real_producer_output(self) -> None:
r"""Round-trip guard against producer/consumer separator drift.
Feeds real `format_content_with_line_numbers` output (the authoritative
producer, in the deepagents package) through the TUI parser. If the
producer separator ever changes without this parser following, the exact
assertion fails in CI instead of the gutter silently failing to compact
(or, on a widened separator, leaking a phantom space into every source
line). Line 2 is tab-indented source — the ambiguity this format fixes.
"""
output = format_content_with_line_numbers(
["def f():", "\treturn 1"], start_line=1
)
compacted = ToolCallMessage._compact_line_gutter(output)
assert compacted == "1 def f():\n2 \treturn 1"
def test_compact_line_gutter_round_trips_continuation_and_padding(self) -> None:
r"""Real-producer round-trip exercising continuation + multi-digit padding.
The base round-trip test feeds short, unpadded lines. This one forces a
wrapped line (an `N.M` continuation marker) and a two-digit line number,
so the continuation marker drives the column width and the shorter
markers get leading-space padding — all through the real producer, not a
hand-built string. Line 3 is tab-indented source, which must survive.
Extends drift protection to the continuation and padding paths, not just
the base separator.
"""
long_line = "x" * (MAX_LINE_LENGTH + 5) # forces an `N.1` continuation
output = format_content_with_line_numbers(
["short", long_line, "\treturn 1"], start_line=9
)
compacted = ToolCallMessage._compact_line_gutter(output)
lines = compacted.split("\n")
assert lines[0] == " 9 short" # width 4, driven by the "10.1" marker
assert lines[2].startswith("10.1 ")
assert lines[-1] == " 11 \treturn 1" # tab-indented source survives
def test_compact_line_gutter_preserves_double_spaced_source(self) -> None:
r"""Only the first separator is consumed; the rest of the source is verbatim.
A source line whose own text starts with digits and two spaces
(`"42 meaning"`) must survive intact: the regex captures the leading
gutter marker and emits everything after the first two-space separator
untouched, including the embedded double space. Guards the `(.*)` capture
against a future separator group that might reprocess or collapse
interior spacing (e.g. widening `(?: |\t)` to `\s+`).
"""
# width 2 (max marker "10"); row 5's source begins with digits + 2 spaces.
output = "5 42 meaning\n10 ok"
compacted = ToolCallMessage._compact_line_gutter(output)
assert compacted == " 5 42 meaning\n10 ok"
def test_compact_line_gutter_passes_through_non_numbered(self) -> None:
"""Output without a cat -n gutter is returned unchanged."""
"""Output without a gutter is returned unchanged."""
output = "plain text\nno line numbers here"
assert ToolCallMessage._compact_line_gutter(output) == output
def test_compact_line_gutter_rejects_malformed_number_heads(self) -> None:
r"""Heads that aren't a bare `N`/`N.M` are treated as source, not gutter.
Guards against corrupting tab-separated data whose first column merely
resembles a number (leading/trailing dot, multiple dots).
Guards against corrupting data whose first column merely resembles a
number (leading/trailing dot, multiple dots) — in both the legacy tab
and current two-space forms, since a malformed head fails the marker
pattern before the separator alternation is ever reached.
"""
# Leading dot, trailing dot, and multi-dot heads must all pass through.
output = " .5\tweird\n 5.\talso\n 1.2.3\tnope"
# Leading dot, trailing dot, and multi-dot heads must all pass through,
# whichever separator follows. All lines are non-gutter, so nothing
# matches and the whole output is returned verbatim.
output = (
" .5\tweird\n 5.\talso\n 1.2.3\tnope\n"
".5 two-space\n5. two-space\n1.2.3 two-space"
)
assert ToolCallMessage._compact_line_gutter(output) == output
def test_compact_line_gutter_preview_truncates_with_compacted_gutters(