Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion server/unified_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ def append_file(old_path: str = "", new_path: str = "") -> dict[str, Any] | None
files.append(item)
return item

for raw_line in source.splitlines():
# Patch lines end at "\n" (or "\r\n"). str.splitlines() would also break
# content at a form feed, U+2028 or another separator inside the line.
for line in source.split("\n"):
raw_line = line.removesuffix("\r")
# A hunk header declares how many old and new lines follow. Until they
# are consumed, "--- x" and "+++ x" are removed/added content such as
# an SQL or Lua comment, not file headers.
Expand Down
7 changes: 4 additions & 3 deletions server/work_artifact_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ async def collect_git_delta(
part.rstrip("\n")
for part in (committed_patch, working_patch, untracked_patch)
if part
).strip(),
),
"committed_patch": committed_patch,
"working_patch": working_patch,
"untracked_patch": untracked_patch,
Expand Down Expand Up @@ -692,7 +692,8 @@ def _untracked_text_patch(
display_path = str(relative_path).replace("\\", "/")
old_marker = "/dev/null"
new_marker = f"b/{display_path}"
lines = text.splitlines()
# Match Git line framing without consuming separators inside source lines.
lines = text.removesuffix("\n").split("\n") if text else []
patch_lines = [
f"diff --git {_quoted_patch_path(f'a/{display_path}')} {_quoted_patch_path(new_marker)}",
"new file mode 100644",
Expand All @@ -702,7 +703,7 @@ def _untracked_text_patch(
if lines:
patch_lines.append(f"@@ -0,0 +1,{len(lines)} @@")
patch_lines.extend(f"+{line}" for line in lines)
if text and not text.endswith(("\n", "\r")):
if not text.endswith("\n"):
patch_lines.append("\\ No newline at end of file")
patches.append("\n".join(patch_lines))
return "\n".join(patches), omissions
Expand Down
26 changes: 26 additions & 0 deletions tests/test_unified_diff_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,31 @@ def test_parse_unified_diff_caps_files_without_merging_hunks() -> None:
assert parsed["files"][0]["deletions"] == 1


def test_parse_unified_diff_splits_hunk_lines_only_at_newlines() -> None:
parsed = parse_unified_diff(
"diff --git a/app.py b/app.py\n"
"--- a/app.py\n"
"+++ b/app.py\n"
"@@ -1,4 +1,4 @@\n"
" def a():\n"
"- s = 'x\f-y'\n"
"+ s = 'x\u2028+y'\n"
" \f\n"
" windows\r\n"
)

file = parsed["files"][0]
assert file["additions"] == 1
assert file["deletions"] == 1
assert file["hunks"][0]["lines"] == [
{"kind": "context", "oldLine": 1, "newLine": 1, "text": "def a():"},
{"kind": "remove", "oldLine": 2, "text": " s = 'x\f-y'"},
{"kind": "add", "newLine": 2, "text": " s = 'x\u2028+y'"},
{"kind": "context", "oldLine": 3, "newLine": 3, "text": "\f"},
{"kind": "context", "oldLine": 4, "newLine": 4, "text": "windows"},
]


if __name__ == "__main__":
test_parse_unified_diff_preserves_dual_line_numbers()
test_parse_unified_diff_keeps_untracked_files_without_hunks()
Expand All @@ -224,4 +249,5 @@ def test_parse_unified_diff_caps_files_without_merging_hunks() -> None:
test_parse_unified_diff_keeps_spaced_paths_without_marker_lines()
test_diff_canvas_payload_keeps_report_for_local_mode_switch()
test_parse_unified_diff_caps_files_without_merging_hunks()
test_parse_unified_diff_splits_hunk_lines_only_at_newlines()
print("unified diff canvas tests passed")
95 changes: 94 additions & 1 deletion tests/test_work_artifact_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from agent_host.work_ledger_store import WorkLedgerStore
from server.unified_diff import parse_unified_diff
from server.work_artifact_registry import WorkArtifactRegistry
from server.work_artifact_registry import WorkArtifactRegistry, _untracked_text_patch


def _git(cwd: Path, *args: str) -> str:
Expand Down Expand Up @@ -439,6 +439,99 @@ async def run() -> None:
asyncio.run(run())


@pytest.mark.parametrize(
("content", "expected_lines", "missing_newline"),
[
("page\f-break\nx\u2028+y\nlast\n", ["page\f-break", "x\u2028+y", "last"], False),
("a\v-b\x1c+c\x1d d\x1e\\e\x85 f\u2029g\n", ["a\v-b\x1c+c\x1d d\x1e\\e\x85 f\u2029g"], False),
("", [], False),
("\n", [""], False),
("first\n\n", ["first", ""], False),
("first\r\n\r\nlast\r\n", ["first", "", "last"], False),
("first\nlast", ["first", "last"], True),
("first\rlast\r", ["first\rlast"], True),
],
ids=[
"embedded-separators", "control-characters", "empty", "blank",
"trailing-blank", "crlf", "no-final-newline", "bare-cr",
],
)
def test_untracked_patch_roundtrip_preserves_git_lines(
tmp_path: Path, content: str, expected_lines: list[str], missing_newline: bool,
) -> None:
(tmp_path / "new file.txt").write_bytes(content.encode("utf-8"))

patch, omissions = _untracked_text_patch(tmp_path, ["new file.txt"])
parsed = parse_unified_diff(
patch, changed_files=["new file.txt"], untracked=["new file.txt"],
)

assert omissions == []
assert parsed["fileCount"] == 1
assert parsed["additions"] == len(expected_lines)
assert parsed["deletions"] == 0
file = parsed["files"][0]
assert file["path"] == "new file.txt"
assert file["status"] == "added"
if not expected_lines:
assert file["hunks"] == []
return
hunk, = file["hunks"]
assert hunk["oldCount"] == 0
assert hunk["newCount"] == len(expected_lines)
expected = [
{"kind": "add", "newLine": number, "text": text}
for number, text in enumerate(expected_lines, 1)
]
if missing_newline:
expected.append({"kind": "meta", "text": "\\ No newline at end of file"})
assert hunk["lines"] == expected


@pytest.mark.parametrize("storage", ["git-untracked", "git-tracked", "filesystem-new"])
def test_attempt_diff_preserves_newlines_and_trailing_content(tmp_path: Path, storage: str) -> None:
async def run() -> None:
root = tmp_path / "workspace"
root.mkdir()
target = root / "new file.txt"
if storage.startswith("git-"):
_git(root, "init")
_git(root, "config", "user.email", "amadeus-test@example.invalid")
_git(root, "config", "user.name", "Amadeus Test")
(root / "README.md").write_bytes(b"baseline\n")
if storage == "git-tracked":
target.write_bytes(b"old\n")
_git(root, "add", ".")
_git(root, "commit", "-m", "baseline")
with WorkLedgerStore(tmp_path / "ledger.sqlite3") as store:
project = store.create_or_get_project(root)
item = store.create_work_item(project.project_id, title="Generate file")
attempt = store.create_attempt(item.work_item_id, provider="codex", task="Generate file")
registry = WorkArtifactRegistry(store)
await registry.capture_baseline(attempt, item)
target.write_bytes("page\f-break\nx\u2028+y\nlast\f \n".encode("utf-8"))
current_attempt = store.get_attempt(attempt.attempt_id)
assert current_attempt is not None
delta = await registry.finalize_attempt(current_attempt, item)

assert delta["available"] is True
parsed = parse_unified_diff(delta["patch"], changed_files=delta["changed_files"])
assert parsed["fileCount"] == 1
assert parsed["additions"] == 3
assert parsed["deletions"] == (1 if storage == "git-tracked" else 0)
file = parsed["files"][0]
assert file["path"] == "new file.txt"
assert [
line for hunk in file["hunks"] for line in hunk["lines"] if line["kind"] == "add"
] == [
{"kind": "add", "newLine": 1, "text": "page\f-break"},
{"kind": "add", "newLine": 2, "text": "x\u2028+y"},
{"kind": "add", "newLine": 3, "text": "last\f "},
]

asyncio.run(run())


def _main() -> None:
test_committed_delta_survives_and_baseline_dirty_is_not_stolen()
test_changed_preexisting_dirty_path_is_ambiguous_not_claimed_exact()
Expand Down
Loading