Skip to content
Closed
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
58 changes: 58 additions & 0 deletions tests/tools/test_file_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -851,3 +851,61 @@ def test_relative_write_after_env_cleanup_lands_in_user_cwd(self, tmp_path, monk
"file silently misplaced into config default (the #26211 bug)"

ft._last_known_cwd.pop(task_id, None)


class TestTruncationSignatureGuard:
"""Regression tests for AI truncation placeholder detection (PR #20857).

When an AI model omits file sections it considers unchanged, it often
emits placeholder strings like '// ... unchanged ...' or
'/* ... full function ... */'. Writing these to disk produces corrupt
files that cannot be compiled or run. The guard must block write, replace,
and V4A patch paths.
"""

def test_check_truncation_signatures_detects_placeholder(self):
"""_check_truncation_signatures returns an error for truncation placeholders."""
from tools.file_tools import _check_truncation_signatures
result = _check_truncation_signatures("def foo():\n // ... unchanged ...\n pass")
assert result is not None
assert "truncation placeholder" in result.lower()

def test_check_truncation_signatures_allows_preexisting(self):
"""Placeholders already in the original file must not be re-flagged."""
from tools.file_tools import _check_truncation_signatures
original = "def foo():\n // ... unchanged ...\n pass"
new_content = original + "\ndef bar(): pass"
result = _check_truncation_signatures(new_content, original)
assert result is None, "Pre-existing placeholder must not block the write"

def test_check_truncation_signatures_clean_content_passes(self):
"""Normal code without placeholders must not be blocked."""
from tools.file_tools import _check_truncation_signatures
result = _check_truncation_signatures("def foo():\n return 42\n")
assert result is None

def test_write_file_blocks_on_truncation_placeholder(self, tmp_path, monkeypatch):
"""write_file_tool must reject content with AI truncation placeholders."""
import tools.file_tools as ft
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Seed a task environment
task_file = tmp_path / "blocked.py"
content_with_placeholder = (
"def foo():\n"
" # ... rest of file ...\n"
" pass\n"
)
result = json.loads(ft.write_file_tool(str(task_file), content_with_placeholder))
assert result.get("error"), (
"write_file_tool must return an error for content with "
"AI truncation placeholders, got: " + str(result)
)
assert "truncation" in str(result.get("error", "")).lower()

def test_write_file_allows_clean_content(self, tmp_path, monkeypatch):
"""write_file_tool must succeed for content without placeholders."""
import tools.file_tools as ft
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
task_file = tmp_path / "clean.py"
result = json.loads(ft.write_file_tool(str(task_file), "def foo():\n return 42\n"))
assert not result.get("error"), f"Clean write must succeed: {result}"
58 changes: 58 additions & 0 deletions tools/file_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1635,6 +1635,46 @@ def _mark_verification_stale(
logger.debug("verification stale marker failed", exc_info=True)


# AI truncation signatures: placeholder strings that indicate the model
# produced incomplete/abbreviated content rather than real file content.
_TRUNCATION_SIGNATURES: tuple[str, ...] = (
"/* ... full function ... */",
"/* ... unchanged ... */",
"// ... unchanged ...",
"// ... rest of file ...",
"# ... rest of file ...",
"# ... unchanged ...",
"/* ... rest unchanged ... */",
"... (rest of file unchanged)",
"... (unchanged)",
"<!-- ... unchanged ... -->",
"# ... (rest of the function remains the same)",
"// ... (rest of the function remains the same)",
)


def _check_truncation_signatures(content: str, original: str | None = None) -> str | None:
"""Return an error message if content contains AI truncation placeholders.

Only flags signatures absent from the original file so legitimate comments
are not blocked. When original is None the check is unconditional.
Must be called through the backend-aware file_ops pipeline so docker/modal/
SSH environments see the same file as the eventual write.
"""
content_lower = content.lower()
for sig in _TRUNCATION_SIGNATURES:
sig_lower = sig.lower()
if sig_lower in content_lower:
if original is None or sig_lower not in original.lower():
return (
f"Refusing to write: content contains AI truncation placeholder "
f"{sig!r}. This indicates the content is incomplete — "
"re-read the file fully and reconstruct the complete content "
"before writing."
)
return None


def write_file_tool(path: str, content: str, task_id: str = "default",
cross_profile: bool = False,
session_id: str | None = None) -> str:
Expand All @@ -1659,6 +1699,12 @@ def write_file_tool(path: str, content: str, task_id: str = "default",
"Strip read_file line-number prefixes or reconstruct the intended "
"file contents before writing."
)
# Reject AI truncation placeholders unconditionally on write: there is no
# original to compare against at this point, and a write_file that contains
# "// ... unchanged ..." is never correct.
_trunc_err = _check_truncation_signatures(content)
if _trunc_err:
return tool_error(_trunc_err)
try:
# Resolve once for the registry lock + stale check. Failures here
# fall back to the legacy path — write proceeds, per-task staleness
Expand Down Expand Up @@ -1837,10 +1883,22 @@ def _reject_v4a_traversal(v4a_path: str) -> str | None:
# path would let the two layers disagree about which file is
# being edited.
_replace_target = _path_to_resolved.get(path) or path
# Guard against AI truncation placeholders in new_string.
# Compare against old_string so a pre-existing placeholder
# in the region being replaced is not re-flagged.
_trunc_err = _check_truncation_signatures(new_string, old_string)
if _trunc_err:
return tool_error(_trunc_err)
result = file_ops.patch_replace(_replace_target, old_string, new_string, replace_all)
elif mode == "patch":
if not patch:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scans the entire V4A payload, including - and context lines. The parser preserves those separately from + lines (tools/patch_parser.py:186-197), so a patch that removes or anchors on an existing literal placeholder is incorrectly rejected. Parse and validate only added content (including Add-file content) to preserve the stated pre-existing-signature exception.

return tool_error("patch content required")
# V4A +/* ... full function ... */ patterns are a common
# truncation placeholder in V4A payloads. Check unconditionally
# since there is no convenient old_string to compare against.
_trunc_err = _check_truncation_signatures(patch)
if _trunc_err:
return tool_error(_trunc_err)
result = file_ops.patch_v4a(patch)
else:
return tool_error(f"Unknown mode: {mode}")
Expand Down
Loading