diff --git a/tests/agent/lsp/test_shell_linter_lsp_skip.py b/tests/agent/lsp/test_shell_linter_lsp_skip.py
new file mode 100644
index 00000000000..a101fa9e1be
--- /dev/null
+++ b/tests/agent/lsp/test_shell_linter_lsp_skip.py
@@ -0,0 +1,210 @@
+"""Skip the per-file shell linter when LSP will handle the same file.
+
+The per-file ``npx tsc --noEmit FILE.ts`` shell linter cannot see
+``tsconfig.json`` (a documented ``tsc`` quirk: explicit file args bypass
+the project config), so it defaults to no-lib / ES5 and floods the
+agent's lint field with phantom "Cannot find 'Promise' / 'Map' / 'Set' /
+'ReadonlySet' / 'Iterable' / 'imul' / …" errors on every edit — up to
+25K tokens per patch. The LSP tier (``tsserver`` via
+typescript-language-server) reads tsconfig correctly and surfaces real
+diagnostics in the ``lsp_diagnostics`` field of the WriteResult /
+PatchResult.
+
+These tests pin the contract:
+
+ - When LSP is active AND ``enabled_for(path)`` for a ``.ts`` / ``.go``
+ / ``.rs`` file, ``_check_lint`` returns ``skipped`` without invoking
+ the shell linter at all.
+ - When LSP is inactive or disabled-for-path, the shell linter runs
+ exactly as before (regression guard for the default config).
+ - The skip only applies to extensions in
+ ``_SHELL_LINTER_LSP_REDUNDANT`` — Python ``py_compile`` and
+ ``node --check`` keep running unconditionally because they're fast,
+ file-local, and correct.
+ - ``.tsx`` is intentionally NOT in either ``LINTERS`` or
+ ``_SHELL_LINTER_LSP_REDUNDANT``: it had no ``LINTERS`` entry
+ pre-PR (so it was already implicitly ``skipped`` via the
+ ``ext not in LINTERS`` branch) and adding one would have inherited
+ ``.ts``'s broken ``tsc --noEmit FILE`` invocation for LSP-disabled
+ users. When LSP IS enabled, ``.tsx`` is still covered by
+ typescript-language-server via ``_maybe_lsp_diagnostics`` — the
+ diagnostics show up on ``lsp_diagnostics``, not ``lint``.
+"""
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+def _make_fops():
+ from tools.environments.local import LocalEnvironment
+ from tools.file_operations import ShellFileOperations
+ return ShellFileOperations(LocalEnvironment())
+
+
+@pytest.mark.parametrize("ext", [".ts", ".go", ".rs"])
+def test_shell_linter_skipped_when_lsp_will_handle(ext, tmp_path):
+ """When LSP is active and enabled_for(path), shell linter is skipped.
+
+ The shell linter's _exec must NOT be called — that's the whole
+ point. We assert by patching ``_exec`` to raise, so any accidental
+ invocation surfaces as a test failure.
+ """
+ fops = _make_fops()
+ src = tmp_path / f"bad{ext}"
+ src.write_text("intentionally invalid content\n")
+
+ def _exec_must_not_run(*args, **kwargs): # pragma: no cover
+ raise AssertionError(
+ "shell linter was invoked despite LSP claiming the file"
+ )
+
+ with patch.object(fops, "_lsp_will_handle", return_value=True), \
+ patch.object(fops, "_exec", side_effect=_exec_must_not_run), \
+ patch.object(fops, "_has_command", return_value=True):
+ result = fops._check_lint(str(src))
+
+ assert result.skipped is True
+ assert "LSP" in (result.message or "")
+
+
+@pytest.mark.parametrize("ext", [".ts", ".go", ".rs"])
+def test_shell_linter_runs_when_lsp_inactive(ext, tmp_path):
+ """When LSP is inactive (default config, no service, remote backend, ...),
+ the shell linter runs as before — no behavior change."""
+ fops = _make_fops()
+ src = tmp_path / f"clean{ext}"
+ src.write_text("// content\n")
+
+ fake_result = MagicMock()
+ fake_result.exit_code = 0
+ fake_result.stdout = ""
+
+ with patch.object(fops, "_lsp_will_handle", return_value=False), \
+ patch.object(fops, "_exec", return_value=fake_result) as exec_mock, \
+ patch.object(fops, "_has_command", return_value=True):
+ result = fops._check_lint(str(src))
+
+ # _exec must have been called — proving the shell linter ran.
+ assert exec_mock.called, "shell linter did NOT run when LSP was inactive"
+ assert result.success is True
+
+
+@pytest.mark.parametrize("ext", [".py", ".js"])
+def test_lsp_does_not_skip_non_redundant_extensions(ext, tmp_path):
+ """``py_compile`` and ``node --check`` keep running even when an LSP
+ server (pyright/pylsp/typescript-language-server-for-JS) is active —
+ they're fast, file-local, and correct, so there's no upside to
+ suppressing them.
+ """
+ fops = _make_fops()
+ src = tmp_path / f"clean{ext}"
+ src.write_text("# valid\n" if ext == ".py" else "// valid\n")
+
+ fake_result = MagicMock()
+ fake_result.exit_code = 0
+ fake_result.stdout = ""
+
+ # Even with LSP claiming the file, the shell linter must still run
+ # for these extensions.
+ with patch.object(fops, "_lsp_will_handle", return_value=True), \
+ patch.object(fops, "_exec", return_value=fake_result) as exec_mock, \
+ patch.object(fops, "_has_command", return_value=True):
+ fops._check_lint(str(src))
+
+ assert exec_mock.called, (
+ f"shell linter for {ext} did not run despite being in the "
+ "'always-run' set (py_compile / node --check)"
+ )
+
+
+def test_lsp_will_handle_returns_false_when_service_is_none(tmp_path):
+ """``_lsp_will_handle`` must return False when the LSP service hasn't
+ been initialized — otherwise we'd accidentally skip the shell linter
+ on systems where LSP isn't configured at all."""
+ fops = _make_fops()
+ src = tmp_path / "foo.ts"
+ src.write_text("const x = 1\n")
+
+ with patch.object(fops, "_lsp_local_only", return_value=True), \
+ patch("agent.lsp.get_service", return_value=None):
+ assert fops._lsp_will_handle(str(src)) is False
+
+
+def test_lsp_will_handle_returns_false_on_remote_backend(tmp_path):
+ """LSP servers run on the host process — remote backends (Docker,
+ SSH, Modal, …) keep files inside the sandbox where the host LSP
+ can't reach them. ``_lsp_will_handle`` must short-circuit before
+ calling into the service in that case."""
+ fops = _make_fops()
+ src = tmp_path / "foo.ts"
+ src.write_text("const x = 1\n")
+
+ with patch.object(fops, "_lsp_local_only", return_value=False), \
+ patch("agent.lsp.get_service") as get_service_mock:
+ result = fops._lsp_will_handle(str(src))
+
+ assert result is False
+ # Importantly: we never even consulted the service.
+ assert not get_service_mock.called
+
+
+def test_lsp_will_handle_swallows_enabled_for_exception(tmp_path):
+ """A flaky LSP service must never break the shell-linter fallback —
+ if ``enabled_for`` raises, we treat the file as "not handled" so the
+ shell linter still runs."""
+ fops = _make_fops()
+ src = tmp_path / "foo.ts"
+ src.write_text("const x = 1\n")
+
+ fake_svc = MagicMock()
+ fake_svc.enabled_for.side_effect = RuntimeError("server crashed")
+
+ with patch.object(fops, "_lsp_local_only", return_value=True), \
+ patch("agent.lsp.get_service", return_value=fake_svc):
+ assert fops._lsp_will_handle(str(src)) is False
+
+
+def test_tsx_stays_out_of_linters_table_for_default_compatibility():
+ """Regression: keep ``.tsx`` out of ``LINTERS`` so users with LSP
+ DISABLED don't suddenly get the broken ``npx tsc --noEmit FILE.tsx``
+ invocation that ``.ts`` historically used to get.
+
+ Pre-PR behavior: ``.tsx`` had no entry in ``LINTERS``, so it fell
+ through to ``ext not in LINTERS`` → ``LintResult(skipped=True,
+ message="No linter for .tsx files")``. This PR preserves that for
+ the default config.
+
+ When LSP IS enabled, ``.tsx`` is still covered by the LSP tier via
+ ``_maybe_lsp_diagnostics`` (typescript-language-server claims
+ ``.tsx`` in its extensions list) — the diagnostics show up in the
+ ``lsp_diagnostics`` field, not the ``lint`` field.
+ """
+ from tools.file_operations import LINTERS, _SHELL_LINTER_LSP_REDUNDANT
+
+ assert ".tsx" not in LINTERS
+ assert ".tsx" not in _SHELL_LINTER_LSP_REDUNDANT
+
+
+def test_tsx_default_check_lint_returns_skipped(tmp_path):
+ """End-to-end: ``.tsx`` files get ``LintResult(skipped=True)`` from
+ ``_check_lint`` regardless of LSP status — this is the no-regression
+ contract that addresses Copilot review #3271017282."""
+ fops = _make_fops()
+ src = tmp_path / "foo.tsx"
+ src.write_text("export const X = () =>
\n")
+
+ # Even with LSP claiming the file, no shell linter runs for .tsx
+ # because there's no LINTERS entry — the ``ext not in LINTERS``
+ # branch fires before the LSP short-circuit is consulted.
+ with patch.object(fops, "_lsp_will_handle", return_value=True), \
+ patch.object(fops, "_exec") as exec_mock:
+ result = fops._check_lint(str(src))
+
+ assert result.skipped is True
+ assert not exec_mock.called, "no shell linter should run for .tsx"
+
+
+if __name__ == "__main__": # pragma: no cover
+ pytest.main([__file__, "-v"])
diff --git a/tests/tools/test_patch_parser.py b/tests/tools/test_patch_parser.py
index 8c4a0c80a37..79077a84a16 100644
--- a/tests/tools/test_patch_parser.py
+++ b/tests/tools/test_patch_parser.py
@@ -509,3 +509,141 @@ class TestParseErrorSignalling:
ops, err = parse_v4a_patch(patch)
assert err is None
assert len(ops) == 1
+
+
+class TestV4ALspDiagnosticsPropagation:
+ """V4A patches must surface ``WriteResult.lsp_diagnostics`` from the
+ underlying ``write_file`` calls on ``PatchResult.lsp_diagnostics``.
+
+ Without explicit propagation the LSP tier's output gets silently
+ dropped on the V4A code path — see Copilot review #3271017295 on
+ PR #29054. The shell-linter LSP skip introduced by that PR makes
+ this gap visible: a ``.ts`` / ``.go`` / ``.rs`` V4A patch with LSP
+ active would otherwise return ``lint = {f: {skipped: True, ...}}``
+ and zero diagnostics from any channel.
+ """
+
+ def _build_ops_writing(self, path: str, content: str):
+ """Build a single ADD operation that writes ``content`` to ``path``."""
+ # Use the V4A parser so we don't have to construct PatchOperation
+ # / Hunk / Line objects by hand.
+ lines = "\n".join(f"+{line}" for line in content.splitlines())
+ patch_text = (
+ "*** Begin Patch\n"
+ f"*** Add File: {path}\n"
+ f"{lines}\n"
+ "*** End Patch"
+ )
+ ops, err = parse_v4a_patch(patch_text)
+ assert err is None, err
+ return ops
+
+ def test_lsp_diagnostics_propagated_from_write_file_on_add(self):
+ """ADD op: ``WriteResult.lsp_diagnostics`` flows through to
+ ``PatchResult.lsp_diagnostics``."""
+ ops = self._build_ops_writing("foo.ts", "const x: number = 1\n")
+
+ diag_block = (
+ "\n"
+ "ERROR [1:7] some diagnostic\n"
+ ""
+ )
+
+ class FakeFileOps:
+ def write_file(self, path, content):
+ return SimpleNamespace(error=None, lsp_diagnostics=diag_block)
+
+ def _check_lint(self, path):
+ return SimpleNamespace(to_dict=lambda: {"skipped": True})
+
+ result = apply_v4a_operations(ops, FakeFileOps())
+
+ assert result.success is True
+ assert result.lsp_diagnostics == diag_block
+
+ def test_lsp_diagnostics_propagated_from_write_file_on_update(self):
+ """UPDATE op: ``WriteResult.lsp_diagnostics`` flows through to
+ ``PatchResult.lsp_diagnostics``."""
+ patch_text = (
+ "*** Begin Patch\n"
+ "*** Update File: bar.ts\n"
+ "-old\n"
+ "+new\n"
+ "*** End Patch"
+ )
+ ops, err = parse_v4a_patch(patch_text)
+ assert err is None
+
+ diag_block = (
+ "\n"
+ "ERROR [3:1] something\n"
+ ""
+ )
+
+ class FakeFileOps:
+ def read_file_raw(self, path):
+ return SimpleNamespace(content="ctx\nold\nctx\n", error=None)
+
+ def write_file(self, path, content):
+ return SimpleNamespace(error=None, lsp_diagnostics=diag_block)
+
+ def _check_lint(self, path):
+ return SimpleNamespace(to_dict=lambda: {"skipped": True})
+
+ result = apply_v4a_operations(ops, FakeFileOps())
+
+ assert result.success is True
+ assert result.lsp_diagnostics == diag_block
+
+ def test_lsp_diagnostics_none_when_no_blocks_emitted(self):
+ """When no underlying ``write_file`` produced diagnostics, the
+ aggregated field stays ``None`` (so it doesn't get serialized
+ as an empty string in ``PatchResult.to_dict``)."""
+ ops = self._build_ops_writing("foo.py", "x = 1\n")
+
+ class FakeFileOps:
+ def write_file(self, path, content):
+ # lsp_diagnostics omitted entirely (older WriteResult shape).
+ return SimpleNamespace(error=None)
+
+ def _check_lint(self, path):
+ return SimpleNamespace(to_dict=lambda: {"success": True})
+
+ result = apply_v4a_operations(ops, FakeFileOps())
+
+ assert result.success is True
+ assert result.lsp_diagnostics is None
+
+ def test_lsp_diagnostics_combined_across_multiple_files(self):
+ """When several files in one V4A patch produce diagnostics,
+ each block appears in the combined output so per-file attribution
+ is preserved."""
+ patch_text = (
+ "*** Begin Patch\n"
+ "*** Add File: a.ts\n"
+ "+const a = 1\n"
+ "*** Add File: b.ts\n"
+ "+const b = 2\n"
+ "*** End Patch"
+ )
+ ops, err = parse_v4a_patch(patch_text)
+ assert err is None
+
+ per_file = {
+ "a.ts": "\nERR a\n",
+ "b.ts": "\nERR b\n",
+ }
+
+ class FakeFileOps:
+ def write_file(self, path, content):
+ return SimpleNamespace(error=None, lsp_diagnostics=per_file[path])
+
+ def _check_lint(self, path):
+ return SimpleNamespace(to_dict=lambda: {"skipped": True})
+
+ result = apply_v4a_operations(ops, FakeFileOps())
+
+ assert result.success is True
+ assert result.lsp_diagnostics is not None
+ assert per_file["a.ts"] in result.lsp_diagnostics
+ assert per_file["b.ts"] in result.lsp_diagnostics
diff --git a/tools/file_operations.py b/tools/file_operations.py
index 13d9314b912..c25dc332cb0 100644
--- a/tools/file_operations.py
+++ b/tools/file_operations.py
@@ -326,6 +326,44 @@ LINTERS = {
'.rs': 'rustfmt --check {file} 2>&1',
}
+# Extensions where the per-file shell linter is structurally weaker than
+# a real LSP server AND produces phantom errors on real-world projects:
+#
+# - ``.ts``: ``tsc --noEmit FILE.ts`` ignores ``tsconfig.json`` and
+# defaults to no-lib / ES5, so every ES2015+ stdlib reference
+# (``Promise``, ``Map``, ``Set``, ``ReadonlySet``, ``Iterable``,
+# ``Math.imul``, ``Number.isFinite``, etc.) reports as missing. This
+# floods the agent's lint field with 20K+ tokens of false positives on
+# every edit. No supported tsc flag fixes the single-file invocation;
+# the canonical replacement is ``tsserver`` via LSP, which respects
+# tsconfig and gives true diagnostics.
+#
+# ``.tsx`` is intentionally NOT in ``LINTERS`` (and therefore not
+# here): it has no shell linter entry, so it falls through to the
+# ``ext not in LINTERS`` skip case unchanged. Pre-PR behavior:
+# ``.tsx`` was implicitly ``skipped``. Keeping it that way means
+# ``.tsx`` edits with LSP disabled get no per-file syntax check
+# (same as before this PR) instead of the broken ``tsc`` invocation
+# that ``.ts`` used to get. When LSP is enabled, ``.tsx`` is covered
+# by the LSP tier via ``_maybe_lsp_diagnostics`` exactly as ``.ts``.
+#
+# - ``.go``: ``go vet FILE.go`` fails outside a module / GOPATH with
+# "cannot find package" — already partially handled by
+# ``_LINTER_UNUSABLE_PATTERNS`` but only when the package error is the
+# ONLY output; mixed real+phantom output still leaks through.
+# ``gopls`` is the canonical replacement.
+#
+# - ``.rs``: ``rustfmt --check FILE.rs`` is style, not type-checking, and
+# rejects non-Cargo project files. ``rust-analyzer`` is the canonical
+# replacement.
+#
+# When the LSP service is configured AND ``enabled_for(path)`` for this
+# extension's file, ``_check_lint`` skips the shell linter for these
+# extensions — the ``lsp_diagnostics`` channel carries the real signal.
+# Everything else in ``LINTERS`` (Python ``py_compile``, ``node --check``)
+# is fast, file-local, and correct, so it runs unconditionally.
+_SHELL_LINTER_LSP_REDUNDANT = frozenset({'.ts', '.go', '.rs'})
+
# Patterns that indicate the linter base command exists on PATH but
# couldn't actually run — e.g. ``npx tsc`` when tsc isn't installed in
@@ -1169,6 +1207,19 @@ class ShellFileOperations(FileOperations):
if ext not in LINTERS:
return LintResult(skipped=True, message=f"No linter for {ext} files")
+ # If a real LSP server is active and claims this file, skip the
+ # shell linter for extensions whose per-file shell invocation is
+ # structurally weaker / floods phantom errors. See
+ # ``_SHELL_LINTER_LSP_REDUNDANT`` above for the rationale per ext.
+ # The LSP tier runs separately via ``_maybe_lsp_diagnostics`` and
+ # carries the real diagnostics in ``lsp_diagnostics`` on the
+ # WriteResult / PatchResult.
+ if ext in _SHELL_LINTER_LSP_REDUNDANT and self._lsp_will_handle(path):
+ return LintResult(
+ skipped=True,
+ message=f"LSP server handles {ext} — shell linter skipped",
+ )
+
linter_cmd = LINTERS[ext]
# Extract the base command (first word)
base_cmd = linter_cmd.split()[0]
@@ -1332,6 +1383,40 @@ class ShellFileOperations(FileOperations):
return True
return False
+ def _lsp_will_handle(self, path: str) -> bool:
+ """Return True iff the LSP service is active AND will lint this file.
+
+ Stronger than :meth:`_lsp_handles_extension` — that one only checks
+ the static server registry. This one additionally requires the
+ LSP service to be configured/enabled and the file to pass
+ :meth:`agent.lsp.manager.LSPService.enabled_for` (which gates on
+ workspace detection, disabled-server set, and the broken-pair
+ short-circuit).
+
+ Used by :meth:`_check_lint` to decide whether to skip the per-file
+ shell linter for extensions in ``_SHELL_LINTER_LSP_REDUNDANT``.
+
+ Best-effort: any failure path returns False so the shell linter
+ runs as before — never suppress lint based on an LSP probe that
+ couldn't actually answer the question.
+ """
+ if not self._lsp_local_only():
+ return False
+ try:
+ from agent.lsp import get_service
+ except Exception: # noqa: BLE001
+ return False
+ try:
+ svc = get_service()
+ except Exception: # noqa: BLE001
+ return False
+ if svc is None:
+ return False
+ try:
+ return bool(svc.enabled_for(path))
+ except Exception: # noqa: BLE001
+ return False
+
def _snapshot_lsp_baseline(self, path: str) -> None:
"""Capture pre-edit LSP diagnostics so the post-write delta is correct.
diff --git a/tools/patch_parser.py b/tools/patch_parser.py
index dacc6e855c3..e16cb446ee0 100644
--- a/tools/patch_parser.py
+++ b/tools/patch_parser.py
@@ -363,6 +363,12 @@ def apply_v4a_operations(operations: List[PatchOperation],
files_created = []
files_deleted = []
all_diffs = []
+ # Per-file LSP diagnostics blocks captured from underlying write_file
+ # calls. V4A bypasses the WriteResult / PatchResult plumbing that
+ # write_file and patch_replace use, so without explicit propagation
+ # the LSP tier's output gets silently dropped — see
+ # ``PatchResult.lsp_diagnostics`` aggregation below.
+ lsp_blocks: List[str] = []
errors = []
for op in operations:
@@ -372,6 +378,8 @@ def apply_v4a_operations(operations: List[PatchOperation],
if result[0]:
files_created.append(op.file_path)
all_diffs.append(result[1])
+ if result[2]:
+ lsp_blocks.append(result[2])
else:
errors.append(f"Failed to add {op.file_path}: {result[1]}")
@@ -396,6 +404,8 @@ def apply_v4a_operations(operations: List[PatchOperation],
if result[0]:
files_modified.append(op.file_path)
all_diffs.append(result[1])
+ if result[2]:
+ lsp_blocks.append(result[2])
else:
errors.append(f"Failed to update {op.file_path}: {result[1]}")
@@ -411,6 +421,13 @@ def apply_v4a_operations(operations: List[PatchOperation],
combined_diff = '\n'.join(all_diffs)
+ # Combine per-file LSP diagnostics blocks. Each block already has
+ # the ```` header from
+ # ``LSPService.report_for_file`` so concatenation is safe — the
+ # agent (and any downstream parsers) can still attribute each
+ # diagnostic to its file.
+ combined_lsp = "\n\n".join(lsp_blocks) if lsp_blocks else None
+
if errors:
return PatchResult(
success=False,
@@ -419,6 +436,7 @@ def apply_v4a_operations(operations: List[PatchOperation],
files_created=files_created,
files_deleted=files_deleted,
lint=lint_results if lint_results else None,
+ lsp_diagnostics=combined_lsp,
error="Apply phase failed (state may be inconsistent — run `git diff` to assess):\n"
+ "\n".join(f" • {e}" for e in errors),
)
@@ -430,11 +448,19 @@ def apply_v4a_operations(operations: List[PatchOperation],
files_created=files_created,
files_deleted=files_deleted,
lint=lint_results if lint_results else None,
+ lsp_diagnostics=combined_lsp,
)
-def _apply_add(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
- """Apply an add file operation."""
+def _apply_add(op: PatchOperation, file_ops: Any) -> Tuple[bool, str, Optional[str]]:
+ """Apply an add file operation.
+
+ Returns ``(success, diff_or_error, lsp_diagnostics)``. The third
+ element carries the formatted ```` block from
+ :class:`WriteResult.lsp_diagnostics` so V4A patches can surface
+ semantic diagnostics from the LSP layer — without this, the LSP
+ tier would silently swallow them on the V4A code path.
+ """
# Extract content from hunks (all + lines)
content_lines = []
for hunk in op.hunks:
@@ -446,12 +472,12 @@ def _apply_add(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
result = file_ops.write_file(op.file_path, content)
if result.error:
- return False, result.error
+ return False, result.error, None
diff = f"--- /dev/null\n+++ b/{op.file_path}\n"
diff += '\n'.join(f"+{line}" for line in content_lines)
- return True, diff
+ return True, diff, getattr(result, "lsp_diagnostics", None)
def _apply_delete(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
@@ -485,8 +511,12 @@ def _apply_move(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
return True, diff
-def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
- """Apply an update file operation."""
+def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str, Optional[str]]:
+ """Apply an update file operation.
+
+ Returns ``(success, diff_or_error, lsp_diagnostics)`` — see
+ :func:`_apply_add` for the rationale on the third element.
+ """
# Deferred import: breaks the patch_parser ↔ fuzzy_match circular dependency
from tools.fuzzy_match import fuzzy_find_and_replace
@@ -494,7 +524,7 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
read_result = file_ops.read_file_raw(op.file_path)
if read_result.error:
- return False, f"Cannot read file: {read_result.error}"
+ return False, f"Cannot read file: {read_result.error}", None
current_content = read_result.content
@@ -549,7 +579,7 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
err_msg += format_no_match_hint(error, 0, search_pattern, new_content)
except Exception:
pass
- return False, err_msg
+ return False, err_msg, None
else:
# Addition-only hunk (no context or removed lines).
# Insert at the location indicated by the context hint, or at end of file.
@@ -563,7 +593,7 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
return False, (
f"Addition-only hunk: context hint '{hunk.context_hint}' is ambiguous "
f"({occurrences} occurrences) — provide a more unique hint"
- )
+ ), None
else:
hint_pos = new_content.find(hunk.context_hint)
# Insert after the line containing the context hint
@@ -578,7 +608,7 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
# Write new content
write_result = file_ops.write_file(op.file_path, new_content)
if write_result.error:
- return False, write_result.error
+ return False, write_result.error, None
# Generate diff
diff_lines = difflib.unified_diff(
@@ -589,4 +619,4 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
)
diff = ''.join(diff_lines)
- return True, diff
+ return True, diff, getattr(write_result, "lsp_diagnostics", None)