python-agent-harness 1.5.4.2__tar.gz → 1.5.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. {python_agent_harness-1.5.4.2/python_agent_harness.egg-info → python_agent_harness-1.5.4.3}/PKG-INFO +2 -2
  2. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/README.md +1 -1
  3. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/pyproject.toml +1 -1
  4. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/mcp/manager.py +5 -1
  6. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/planmode.py +5 -2
  7. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/__init__.py +11 -2
  8. python_agent_harness-1.5.4.3/python_agent_harness/tools/diffapply.py +246 -0
  9. python_agent_harness-1.5.4.3/python_agent_harness/tools/edit_mac.py +56 -0
  10. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/glob.py +4 -1
  11. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/grep.py +1 -1
  12. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3/python_agent_harness.egg-info}/PKG-INFO +2 -2
  13. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/SOURCES.txt +2 -0
  14. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_agents_md.py +5 -5
  15. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_filesystem.py +179 -13
  16. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_invariants.py +26 -0
  17. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_prompts.py +2 -2
  18. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_session.py +16 -4
  19. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/LICENSE +0 -0
  20. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/__main__.py +0 -0
  21. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/agent.py +0 -0
  22. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/cli.py +0 -0
  23. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/client.py +0 -0
  24. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/commands.py +0 -0
  25. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/config.py +0 -0
  26. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/context_manager.py +0 -0
  27. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/diffrender.py +0 -0
  28. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/mcp/__init__.py +0 -0
  29. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/mcp/client.py +0 -0
  30. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/mcp/config.py +0 -0
  31. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/models.py +0 -0
  32. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/persistence.py +0 -0
  33. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/agent.md +0 -0
  34. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/build-switch.md +0 -0
  35. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/commands/explain.md +0 -0
  36. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/compact.md +0 -0
  37. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/initialize.md +0 -0
  38. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/plan-mode.md +0 -0
  39. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/plan.md +0 -0
  40. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/review.md +0 -0
  41. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/subagent.md +0 -0
  42. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/summary.md +0 -0
  43. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  44. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/title.md +0 -0
  45. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts.py +0 -0
  46. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/session.py +0 -0
  47. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/subagent.py +0 -0
  48. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/token_estimator.py +0 -0
  49. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tool_runner.py +0 -0
  50. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/agent_tool.py +0 -0
  51. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/base.py +0 -0
  52. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/bash.py +0 -0
  53. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/edit.py +0 -0
  54. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/filesystem.py +0 -0
  55. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/insert.py +0 -0
  56. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/mcp.py +0 -0
  57. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/mkdir.py +0 -0
  58. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/planexit.py +0 -0
  59. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/question.py +0 -0
  60. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/read.py +0 -0
  61. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/skill.py +0 -0
  62. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/todo.py +0 -0
  63. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/write.py +0 -0
  64. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/__init__.py +0 -0
  65. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/commands.py +0 -0
  66. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/core.py +0 -0
  67. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/input.py +0 -0
  68. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/render.py +0 -0
  69. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  70. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/entry_points.txt +0 -0
  71. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/requires.txt +0 -0
  72. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/top_level.txt +0 -0
  73. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/setup.cfg +0 -0
  74. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_cli.py +0 -0
  75. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_client.py +0 -0
  76. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_commands.py +0 -0
  77. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_concurrent_subagents.py +0 -0
  78. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_config.py +0 -0
  79. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_context_rules.py +0 -0
  80. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_diffrender.py +0 -0
  81. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_mcp.py +0 -0
  82. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_models.py +0 -0
  83. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_persistence.py +0 -0
  84. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_planmode.py +0 -0
  85. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_scenarios.py +0 -0
  86. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_subagent.py +0 -0
  87. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_subagent_isolation.py +0 -0
  88. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_todos_scope.py +0 -0
  89. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_token_estimator.py +0 -0
  90. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.3}/tests/test_tools_misc.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.4.2
3
+ Version: 1.5.4.3
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author: huming
6
6
  License-Expression: MIT
@@ -248,7 +248,7 @@ python_agent_harness/
248
248
 
249
249
  ## Development
250
250
 
251
- Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13.
251
+ Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux and macOS.
252
252
 
253
253
  ```sh
254
254
  make test # unit tests
@@ -225,7 +225,7 @@ python_agent_harness/
225
225
 
226
226
  ## Development
227
227
 
228
- Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13.
228
+ Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux and macOS.
229
229
 
230
230
  ```sh
231
231
  make test # unit tests
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.4.2"
7
+ version = "1.5.4.3"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -5,7 +5,7 @@ from .mcp.manager import MCPManager
5
5
  from .models import AgentMode, Message, ToolCall, ToolSpec
6
6
  from .session import Session
7
7
 
8
- __version__ = "1.5.4.2"
8
+ __version__ = "1.5.4.3"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -23,6 +23,7 @@ SDK/connection errors that the tool adapter turns into error strings.
23
23
  from __future__ import annotations
24
24
 
25
25
  import asyncio
26
+ import concurrent.futures
26
27
  import contextlib
27
28
  import threading
28
29
  import time
@@ -102,7 +103,10 @@ class _LoopThread:
102
103
  while True:
103
104
  try:
104
105
  exc = future.exception(timeout=0.1)
105
- except TimeoutError:
106
+ # concurrent.futures.TimeoutError is the builtin TimeoutError on
107
+ # 3.11+ but a distinct class on 3.10 — catch the futures one so
108
+ # the poll-timeout branch works on every supported Python.
109
+ except concurrent.futures.TimeoutError:
106
110
  # future still running: enforce deadline / cancellation
107
111
  if deadline is not None and time.monotonic() >= deadline:
108
112
  future.cancel()
@@ -64,8 +64,11 @@ class PlanMode:
64
64
  os.makedirs(os.path.dirname(path), exist_ok=True)
65
65
  if not os.path.exists(path):
66
66
  Path(path).write_text("", encoding="utf-8")
67
- self.plan_file = path
68
- return path
67
+ # Store the canonical path so the plan-mode write guard (which
68
+ # compares against realpath'd tool targets) matches — on macOS
69
+ # TMPDIR (/var) is a symlink to /private/var.
70
+ self.plan_file = os.path.realpath(path)
71
+ return self.plan_file
69
72
 
70
73
  def cleanup_plan_file(self) -> None:
71
74
  if not self.plan_file:
@@ -2,10 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import sys
6
+
5
7
  from .agent_tool import AgentTool
6
8
  from .base import PendingToolResult, Registry, Tool, ToolContext
7
9
  from .bash import Bash
8
- from .filesystem import Edit, GlobTool, Grep, Insert, Mkdir, Read, Write
10
+ from .edit import Edit
11
+ from .edit_mac import EditMac
12
+ from .filesystem import GlobTool, Grep, Insert, Mkdir, Read, Write
9
13
  from .mcp import MCPTool, mcp_tools_from_manager, normalize_mcp_result
10
14
  from .planexit import PlanExit
11
15
  from .question import Question
@@ -20,6 +24,7 @@ __all__ = [
20
24
  "AgentTool",
21
25
  "Bash",
22
26
  "Edit",
27
+ "EditMac",
23
28
  "GlobTool",
24
29
  "Grep",
25
30
  "Insert",
@@ -38,6 +43,10 @@ __all__ = [
38
43
 
39
44
  def default_registry() -> Registry:
40
45
  reg = Registry()
46
+ # macOS's BSD patch rejects well-formed hunks that GNU patch accepts,
47
+ # so macOS uses EditMac (pure-Python diff applier); Linux keeps the
48
+ # patch-binary Edit. Both register under the name "Edit".
49
+ edit_tool = EditMac() if sys.platform == "darwin" else Edit()
41
50
  for tool in (
42
51
  AgentTool(),
43
52
  TodoWrite(),
@@ -45,7 +54,7 @@ def default_registry() -> Registry:
45
54
  Grep(),
46
55
  Read(),
47
56
  Insert(),
48
- Edit(),
57
+ edit_tool,
49
58
  Write(),
50
59
  Mkdir(),
51
60
  Bash(),
@@ -0,0 +1,246 @@
1
+ """Pure-Python unified diff applier (macOS Edit backend).
2
+
3
+ macOS ships Apple's old BSD ``/usr/bin/patch`` (patch 2.0-12u11-Apple),
4
+ which rejects well-formed hunks that GNU patch accepts: asymmetric
5
+ context (leading but no trailing context, or vice versa), hunks whose
6
+ last body line is a ``---``-rendered content line, etc. Rather than
7
+ rewriting every diff into a shape that specific patch binary likes, the
8
+ macOS Edit tool applies diffs here, in Python:
9
+
10
+ - the diff is parsed STRUCTURALLY, so hunk-header counts are advisory
11
+ (they are recounted from the body, like ``_fix_patch_headers``) and
12
+ ``---``/``+++`` content lines are only file headers when followed by
13
+ their ``+++``/``---`` partner;
14
+ - hunks are matched against the file by content with fuzz (context
15
+ lines may mismatch up to ``FUZZ`` times, removed lines must match
16
+ exactly), preferring the position the header claims;
17
+ - hunks are applied bottom-up so earlier positions stay valid;
18
+ - files are written only when every hunk of the section matched.
19
+
20
+ The applier is platform-independent, so its behavior is covered by
21
+ tests on every OS.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import re
28
+
29
+ _HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
30
+ _FILE_OLD_RE = re.compile(r"^---[ \t]")
31
+ _FILE_NEW_RE = re.compile(r"^\+\+\+[ \t]")
32
+ _HUNK_BOUNDARY_RE = re.compile(r"^@@ ")
33
+
34
+ # Maximum number of context lines that may mismatch when anchoring a
35
+ # hunk (removed lines must always match exactly).
36
+ FUZZ = 3
37
+
38
+
39
+ class _Hunk:
40
+ __slots__ = ("old_start", "body")
41
+
42
+ def __init__(self, old_start: int, body: list[tuple[str, str, bool]]) -> None:
43
+ # body entries: (kind, content, no_newline); kind in ' ', '-', '+'.
44
+ self.old_start = old_start
45
+ self.body = body
46
+
47
+ def old_seq(self) -> list[tuple[str, str]]:
48
+ """(kind, content-without-newline) for the lines that must exist
49
+ in the old file (' ' context and '-' removed lines)."""
50
+ return [
51
+ (kind, content.rstrip("\n")) for kind, content, _ in self.body if kind in (" ", "-")
52
+ ]
53
+
54
+
55
+ class _Section:
56
+ __slots__ = ("new_path", "hunks")
57
+
58
+ def __init__(self, new_path: str, hunks: list[_Hunk]) -> None:
59
+ self.new_path = new_path
60
+ self.hunks = hunks
61
+
62
+
63
+ def _section_header_at(lines: list[str], idx: int) -> bool:
64
+ """Whether lines[idx] begins a file section: a ``--- path`` line
65
+ immediately followed by a ``+++ path`` line. Removed/added content
66
+ lines rendered ``---``/``+++`` inside a hunk body are only mistaken
67
+ for headers when the pair shape matches, so the trailing ``+++``
68
+ partner is required (mirrors ``_starts_file_section``)."""
69
+ if idx + 1 >= len(lines):
70
+ return False
71
+ return bool(_FILE_OLD_RE.match(lines[idx]) and _FILE_NEW_RE.match(lines[idx + 1]))
72
+
73
+
74
+ def _parse(diff_text: str) -> list[_Section]:
75
+ """Parse a unified diff into file sections (paths + hunks)."""
76
+ lines = diff_text.splitlines(keepends=True)
77
+ sections: list[_Section] = []
78
+ i = 0
79
+ n = len(lines)
80
+ while i < n:
81
+ if not _section_header_at(lines, i):
82
+ i += 1
83
+ continue
84
+ new_path = lines[i + 1][len("+++") :].strip()
85
+ i += 2
86
+ hunks: list[_Hunk] = []
87
+ while i < n:
88
+ m = _HUNK_HEADER_RE.match(lines[i])
89
+ if m:
90
+ old_start = int(m.group(1))
91
+ body: list[tuple[str, str, bool]] = []
92
+ i += 1
93
+ while (
94
+ i < n
95
+ and not _HUNK_BOUNDARY_RE.match(lines[i])
96
+ and not _section_header_at(lines, i)
97
+ ):
98
+ line = lines[i]
99
+ if line.startswith("\\"): # ""
100
+ if body and body[-1][0] in (" ", "-", "+"):
101
+ kind, content, _ = body[-1]
102
+ body[-1] = (kind, content, True)
103
+ elif line[:1] in (" ", "-", "+"):
104
+ body.append((line[0], line[1:], False))
105
+ i += 1
106
+ hunks.append(_Hunk(old_start, body))
107
+ continue
108
+ if _section_header_at(lines, i):
109
+ break
110
+ i += 1 # stray line (e.g. "diff --git", "index ...") — skip
111
+ sections.append(_Section(new_path, hunks))
112
+ return sections
113
+
114
+
115
+ def _candidate_positions(start: int, limit: int) -> list[int]:
116
+ """Match offsets around *start* (0-based), closest first, within
117
+ ``FUZZ`` lines and bounded by [0, *limit*]."""
118
+ out: list[int] = []
119
+ seen: set[int] = set()
120
+ for d in range(FUZZ + 1):
121
+ for p in (start - d, start + d):
122
+ if 0 <= p <= limit and p not in seen:
123
+ seen.add(p)
124
+ out.append(p)
125
+ return out
126
+
127
+
128
+ def _match_hunk(hunk: _Hunk, file_lines: list[str]) -> int | None:
129
+ """Find the 0-based position where the hunk's old lines match.
130
+
131
+ Removed lines must match exactly; up to ``FUZZ`` context lines may
132
+ mismatch (patch-style fuzz). Pure-insertion hunks (no old lines)
133
+ anchor at ``old_start - 1``.
134
+ """
135
+ old_seq = hunk.old_seq()
136
+ if not old_seq:
137
+ p = hunk.old_start - 1
138
+ return max(0, min(p, len(file_lines)))
139
+ start = hunk.old_start - 1
140
+ limit = len(file_lines) - len(old_seq)
141
+ for p in _candidate_positions(start, limit):
142
+ mismatches = 0
143
+ ok = True
144
+ for i, (kind, content) in enumerate(old_seq):
145
+ file_content = file_lines[p + i].rstrip("\n")
146
+ if kind == "-":
147
+ if file_content != content:
148
+ ok = False
149
+ break
150
+ elif file_content != content:
151
+ mismatches += 1
152
+ if mismatches > FUZZ:
153
+ ok = False
154
+ break
155
+ if ok:
156
+ return p
157
+ return None
158
+
159
+
160
+ def _apply_hunk(hunk: _Hunk, pos: int, file_lines: list[str]) -> None:
161
+ """Replace the matched region with the hunk's new lines in place."""
162
+ old_count = sum(1 for kind, _, _ in hunk.body if kind in (" ", "-"))
163
+ new_block: list[str] = []
164
+ p = pos
165
+ for kind, content, no_newline in hunk.body:
166
+ if kind == " ":
167
+ new_block.append(file_lines[p])
168
+ p += 1
169
+ elif kind == "-":
170
+ p += 1
171
+ else: # "+"
172
+ if no_newline:
173
+ new_block.append(content.rstrip("\n"))
174
+ elif content.endswith("\n"):
175
+ new_block.append(content)
176
+ else:
177
+ new_block.append(content + "\n")
178
+ file_lines[pos : pos + old_count] = new_block
179
+
180
+
181
+ def _resolve_target(new_path: str, cwd: str, fallback_path: str | None) -> str:
182
+ """Resolve a diff's ``+++ path`` against *cwd*, with the single-file
183
+ *fallback_path* as backup when the name does not match an existing
184
+ file (e.g. the model wrote a bare basename for a deep path)."""
185
+ name = new_path
186
+ for prefix in ("a/", "b/"):
187
+ if name.startswith(prefix):
188
+ name = name[len(prefix) :]
189
+ break
190
+ if os.path.isabs(name):
191
+ return name
192
+ target = os.path.join(cwd, name)
193
+ if not os.path.isfile(target) and fallback_path and os.path.isfile(fallback_path):
194
+ return fallback_path
195
+ return target
196
+
197
+
198
+ def _apply_section(section: _Section, cwd: str, fallback_path: str | None) -> tuple[bool, str]:
199
+ target = _resolve_target(section.new_path, cwd, fallback_path)
200
+ if not os.path.isfile(target):
201
+ return False, f"target file does not exist: {target}"
202
+ try:
203
+ with open(target, encoding="utf-8", errors="replace") as f:
204
+ file_lines = f.readlines()
205
+ except OSError as e:
206
+ return False, f"cannot read {target}: {e}"
207
+ # Match every hunk against the ORIGINAL content first; only write
208
+ # when all matched (a failed hunk leaves the file untouched).
209
+ plan: list[tuple[_Hunk, int]] = []
210
+ for hunk in section.hunks:
211
+ pos = _match_hunk(hunk, file_lines)
212
+ if pos is None:
213
+ return (
214
+ False,
215
+ f"hunk at line {hunk.old_start} failed to match the file content",
216
+ )
217
+ plan.append((hunk, pos))
218
+ new_lines = list(file_lines)
219
+ for hunk, pos in reversed(plan): # bottom-up: earlier positions stay valid
220
+ _apply_hunk(hunk, pos, new_lines)
221
+ try:
222
+ with open(target, "w", encoding="utf-8") as f:
223
+ f.writelines(new_lines)
224
+ except OSError as e:
225
+ return False, f"cannot write {target}: {e}"
226
+ return True, f"patched {target}"
227
+
228
+
229
+ def apply_unified_diff(
230
+ diff_text: str, cwd: str, fallback_path: str | None = None
231
+ ) -> tuple[bool, str]:
232
+ """Apply a unified diff to files under *cwd*.
233
+
234
+ Returns ``(ok, message)``; on failure no file is written. Multi-file
235
+ diffs are applied section by section. *fallback_path* is used when a
236
+ section's target cannot be resolved to an existing file (single-file
237
+ mode).
238
+ """
239
+ sections = _parse(diff_text)
240
+ if not sections:
241
+ return False, "no file sections found in diff"
242
+ for section in sections:
243
+ ok, msg = _apply_section(section, cwd, fallback_path)
244
+ if not ok:
245
+ return False, msg
246
+ return True, f"applied {len(sections)} file section(s)"
@@ -0,0 +1,56 @@
1
+ """macOS Edit tool: string replacement like ``Edit``, but diff/patch
2
+ mode applies diffs with the built-in Python applier instead of
3
+ ``/usr/bin/patch``.
4
+
5
+ Apple's BSD ``patch`` (patch 2.0-12u11-Apple) rejects well-formed hunks
6
+ that GNU patch accepts — asymmetric context, ``---``/``+++``-rendered
7
+ content lines at hunk edges, and so on — so model-generated diffs that
8
+ apply cleanly on Linux fail on macOS. The Python applier
9
+ (``diffapply.py``) parses the diff structurally and matches hunks with
10
+ fuzz, so the same diffs apply on both platforms.
11
+
12
+ Only the diff-application strategy differs; the tool name stays
13
+ ``Edit`` so callers, the tool registry and the plan-mode write guard
14
+ are platform-independent.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+
21
+ from ..diffrender import unified_diff
22
+ from .base import ToolContext
23
+ from .diffapply import apply_unified_diff
24
+ from .edit import Edit, _strip_diff_fence
25
+
26
+
27
+ class EditMac(Edit):
28
+ """Edit with a pure-Python diff/patch backend for macOS."""
29
+
30
+ def _apply_patch(self, path: str, cwd: str, diff: str, ctx: ToolContext) -> str:
31
+ text = diff if diff.endswith("\n") else diff + "\n"
32
+ text = _strip_diff_fence(text)
33
+ is_file = os.path.isfile(path)
34
+ old_content = None
35
+ if is_file:
36
+ try:
37
+ with open(path, encoding="utf-8", errors="replace") as f:
38
+ old_content = f.read()
39
+ except OSError:
40
+ old_content = None
41
+ ok, msg = apply_unified_diff(text, cwd, path if is_file else None)
42
+ if not ok:
43
+ return f"Error: Failed to apply diff to {path}.\n{msg}"
44
+ if old_content is not None and os.path.isfile(path):
45
+ try:
46
+ with open(path, encoding="utf-8", errors="replace") as f:
47
+ new_content = f.read()
48
+ diff_text = unified_diff(old_content, new_content, path)
49
+ if diff_text:
50
+ ctx.record_diff(diff_text)
51
+ except OSError:
52
+ pass
53
+ return (
54
+ f"Diff successfully applied to {path}.\n"
55
+ f"Applied with the built-in Python diff applier.\n{msg}"
56
+ )
@@ -70,7 +70,10 @@ class GlobTool(Tool):
70
70
  return f"Error: path {path} is not readable"
71
71
  else:
72
72
  path = ctx.cwd
73
- base = os.path.abspath(path) # directory-file-name + expand-file-name
73
+ # realpath (not abspath): _git_root resolves symlinks (macOS /var ->
74
+ # /private/var), so the base must be canonical or relpath produces a
75
+ # pathspec git rejects as "outside repository".
76
+ base = os.path.realpath(path)
74
77
  depth = args.get("depth")
75
78
 
76
79
  git_root = _git_root(base)
@@ -41,7 +41,7 @@ class Grep(Tool):
41
41
 
42
42
  def run(self, args: dict, ctx: ToolContext) -> str:
43
43
  regex = args["regex"]
44
- path = os.path.abspath(args["path"])
44
+ path = os.path.realpath(args["path"])
45
45
  if not os.path.isdir(path) and not os.path.isfile(path):
46
46
  return f"Error: path {args['path']} is not readable"
47
47
  glob = args.get("glob")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.4.2
3
+ Version: 1.5.4.3
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author: huming
6
6
  License-Expression: MIT
@@ -248,7 +248,7 @@ python_agent_harness/
248
248
 
249
249
  ## Development
250
250
 
251
- Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13.
251
+ Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux and macOS.
252
252
 
253
253
  ```sh
254
254
  make test # unit tests
@@ -44,7 +44,9 @@ python_agent_harness/tools/__init__.py
44
44
  python_agent_harness/tools/agent_tool.py
45
45
  python_agent_harness/tools/base.py
46
46
  python_agent_harness/tools/bash.py
47
+ python_agent_harness/tools/diffapply.py
47
48
  python_agent_harness/tools/edit.py
49
+ python_agent_harness/tools/edit_mac.py
48
50
  python_agent_harness/tools/filesystem.py
49
51
  python_agent_harness/tools/glob.py
50
52
  python_agent_harness/tools/grep.py
@@ -46,7 +46,7 @@ class TestFindAgentsMdFiles(unittest.TestCase):
46
46
  with tempfile.TemporaryDirectory() as d:
47
47
  (Path(d) / "AGENTS.md").write_text("# Rules\n", encoding="utf-8")
48
48
  result = find_agents_md_files(d)
49
- self.assertEqual([Path(p) for p in result], [Path(d) / "AGENTS.md"])
49
+ self.assertEqual([Path(p) for p in result], [(Path(d) / "AGENTS.md").resolve()])
50
50
 
51
51
  @unittest.skipUnless(shutil.which("git"), "git not available")
52
52
  def test_finds_agents_md_in_ancestor(self):
@@ -57,7 +57,7 @@ class TestFindAgentsMdFiles(unittest.TestCase):
57
57
  sub.mkdir(parents=True)
58
58
  (Path(d) / "AGENTS.md").write_text("# Root rules\n", encoding="utf-8")
59
59
  result = find_agents_md_files(str(sub))
60
- self.assertEqual([Path(p) for p in result], [Path(d) / "AGENTS.md"])
60
+ self.assertEqual([Path(p) for p in result], [(Path(d) / "AGENTS.md").resolve()])
61
61
 
62
62
  @unittest.skipUnless(shutil.which("git"), "git not available")
63
63
  def test_stacks_every_ancestor_nearest_first(self):
@@ -76,7 +76,7 @@ class TestFindAgentsMdFiles(unittest.TestCase):
76
76
  result = find_agents_md_files(str(sub))
77
77
  self.assertEqual(
78
78
  [Path(p) for p in result],
79
- [pkg / "AGENTS.md", Path(d) / "AGENTS.md"],
79
+ [(pkg / "AGENTS.md").resolve(), (Path(d) / "AGENTS.md").resolve()],
80
80
  )
81
81
 
82
82
  @unittest.skipUnless(shutil.which("git"), "git not available")
@@ -92,7 +92,7 @@ class TestFindAgentsMdFiles(unittest.TestCase):
92
92
  # above the git root: must NOT be picked up
93
93
  (Path(outer) / "AGENTS.md").write_text("# Outer rules\n", encoding="utf-8")
94
94
  result = find_agents_md_files(str(sub))
95
- self.assertEqual([Path(p) for p in result], [repo / "AGENTS.md"])
95
+ self.assertEqual([Path(p) for p in result], [(repo / "AGENTS.md").resolve()])
96
96
 
97
97
  def test_non_git_dir_is_its_own_bound(self):
98
98
  """Outside a repo the search is bounded by the project dir."""
@@ -147,7 +147,7 @@ class TestAgentsMdAsContextFiles(unittest.TestCase):
147
147
  result = load_context_files(None, extra_files=find_agents_md_files(d))
148
148
  self.assertEqual(
149
149
  result,
150
- f"Request context:\n\nIn file `{agents}`:\n\n# Project Rules\nUse 4 spaces.",
150
+ f"Request context:\n\nIn file `{agents.resolve()}`:\n\n# Project Rules\nUse 4 spaces.",
151
151
  )
152
152
 
153
153
  def test_empty_file_yields_no_context(self):
@@ -6,11 +6,13 @@ import os
6
6
  import re
7
7
  import shutil
8
8
  import subprocess
9
+ import sys
9
10
  import tempfile
10
11
  import unittest
11
12
  from unittest import mock
12
13
 
13
14
  from python_agent_harness.tools.base import ToolContext
15
+ from python_agent_harness.tools.edit_mac import EditMac
14
16
  from python_agent_harness.tools.filesystem import (
15
17
  Edit,
16
18
  GlobTool,
@@ -24,6 +26,13 @@ from python_agent_harness.tools.filesystem import (
24
26
  )
25
27
 
26
28
 
29
+ def edit_tool() -> Edit:
30
+ """The Edit tool active on this platform: Linux uses the patch
31
+ binary, macOS the built-in Python diff applier (Apple's BSD patch
32
+ rejects well-formed hunks that GNU patch accepts)."""
33
+ return EditMac() if sys.platform == "darwin" else Edit()
34
+
35
+
27
36
  def _big_output(lines: int = 6000, width: int = 80) -> str:
28
37
  return "".join(f"line{i:04d}-{'x' * width}\n" for i in range(lines))
29
38
 
@@ -512,11 +521,11 @@ class TestGlobGrepTools(unittest.TestCase):
512
521
  self._mkdir("proj", "sub")
513
522
  open(os.path.join(d, "sub", "b.py"), "w").close()
514
523
  out = GlobTool().run({"pattern": "*.py", "path": d}, self.ctx)
515
- self.assertIn(os.path.join(d, "a.py"), out)
516
- self.assertIn(os.path.join(d, "sub", "b.py"), out)
524
+ self.assertIn(os.path.realpath(os.path.join(d, "a.py")), out)
525
+ self.assertIn(os.path.realpath(os.path.join(d, "sub", "b.py")), out)
517
526
  out1 = GlobTool().run({"pattern": "*.py", "path": d, "depth": 1}, self.ctx)
518
- self.assertIn(os.path.join(d, "a.py"), out1)
519
- self.assertNotIn(os.path.join(d, "sub", "b.py"), out1)
527
+ self.assertIn(os.path.realpath(os.path.join(d, "a.py")), out1)
528
+ self.assertNotIn(os.path.realpath(os.path.join(d, "sub", "b.py")), out1)
520
529
 
521
530
  @unittest.skipUnless(shutil.which("git"), "git not available")
522
531
  def test_glob_and_grep_use_git_backend_in_repo(self):
@@ -527,8 +536,8 @@ class TestGlobGrepTools(unittest.TestCase):
527
536
  f.write(content)
528
537
  subprocess.run(["git", "add", "."], cwd=repo, check=True)
529
538
  out = GlobTool().run({"pattern": "*", "path": repo}, self.ctx)
530
- self.assertIn(os.path.join(repo, "a.py"), out)
531
- self.assertIn(os.path.join(repo, "b.txt"), out)
539
+ self.assertIn(os.path.realpath(os.path.join(repo, "a.py")), out)
540
+ self.assertIn(os.path.realpath(os.path.join(repo, "b.txt")), out)
532
541
  out = Grep().run({"regex": "hello", "path": repo}, self.ctx)
533
542
  self.assertIn("a.py", out)
534
543
  self.assertIn("hello", out)
@@ -537,6 +546,27 @@ class TestGlobGrepTools(unittest.TestCase):
537
546
  self.assertIn("a.py", out)
538
547
  self.assertIn("hello", out)
539
548
 
549
+ @unittest.skipUnless(shutil.which("git"), "git not available")
550
+ def test_glob_and_grep_work_through_symlinked_path(self):
551
+ """Regression: a search path that goes through a symlink (macOS
552
+ /var -> /private/var) must be resolved before the git backend
553
+ computes relpaths, or git rejects the pathspec as outside the
554
+ repository."""
555
+ real = self._mkdir("real")
556
+ link = os.path.join(self.tmp.name, "link")
557
+ os.symlink(real, link)
558
+ subprocess.run(["git", "init", "-q", real], check=True)
559
+ with open(os.path.join(real, "a.py"), "w") as f:
560
+ f.write("hello world\n")
561
+ subprocess.run(["git", "add", "."], cwd=real, check=True)
562
+ out = GlobTool().run({"pattern": "*", "path": link}, self.ctx)
563
+ self.assertNotIn("outside repository", out)
564
+ self.assertNotIn("Glob failed", out)
565
+ self.assertIn(os.path.realpath(os.path.join(real, "a.py")), out)
566
+ out = Grep().run({"regex": "hello", "path": link}, self.ctx)
567
+ self.assertIn("a.py", out)
568
+ self.assertIn("hello", out)
569
+
540
570
  def test_glob_nonexistent_path_errors(self):
541
571
  out = GlobTool().run(
542
572
  {"pattern": "*", "path": os.path.join(self.tmp.name, "nope")}, self.ctx
@@ -604,7 +634,7 @@ class TestEditTool(unittest.TestCase):
604
634
  f.write("line1\nline2\nline3\n")
605
635
  ctx, sess = make_ctx()
606
636
  diff = "--- a/f.txt\n+++ b/f.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+lineTWO\n line3\n"
607
- result = Edit().run({"path": path, "new_str": diff, "diff": True}, ctx)
637
+ result = edit_tool().run({"path": path, "new_str": diff, "diff": True}, ctx)
608
638
  self.assertIn("Diff successfully applied", result)
609
639
  with open(path) as f:
610
640
  self.assertEqual(f.read(), "line1\nlineTWO\nline3\n")
@@ -618,7 +648,7 @@ class TestEditTool(unittest.TestCase):
618
648
  f.write(original)
619
649
  ctx, sess = make_ctx()
620
650
  bad_diff = "--- a/f.txt\n+++ b/f.txt\n@@ -1,2 +1,2 @@\n line1\n-NOPE\n+lineTWO\n"
621
- result = Edit().run({"path": path, "new_str": bad_diff, "diff": True}, ctx)
651
+ result = edit_tool().run({"path": path, "new_str": bad_diff, "diff": True}, ctx)
622
652
  self.assertTrue(result.startswith("Error:"))
623
653
  with open(path) as f:
624
654
  self.assertEqual(f.read(), original) # unchanged on failure
@@ -1134,7 +1164,7 @@ class TestEditDiffModePatch(unittest.TestCase):
1134
1164
  f.write("a\nb\nc\n")
1135
1165
  ctx, _ = make_ctx()
1136
1166
  diff = "```diff\n--- a/f.txt\n+++ b/f.txt\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n```\n"
1137
- result = Edit().run({"path": path, "new_str": diff, "diff": True}, ctx)
1167
+ result = edit_tool().run({"path": path, "new_str": diff, "diff": True}, ctx)
1138
1168
  self.assertIn("Diff successfully applied", result)
1139
1169
  with open(path) as f:
1140
1170
  self.assertEqual(f.read(), "a\nB\nc\n")
@@ -1148,7 +1178,7 @@ class TestEditDiffModePatch(unittest.TestCase):
1148
1178
  f.write("a\nb\nc\n")
1149
1179
  ctx, _ = make_ctx()
1150
1180
  diff = "--- a/f.txt\n+++ b/f.txt\n@@ -1,9 +1,9 @@\n a\n-b\n+B\n c\n"
1151
- result = Edit().run({"path": path, "new_str": diff, "diff": True}, ctx)
1181
+ result = edit_tool().run({"path": path, "new_str": diff, "diff": True}, ctx)
1152
1182
  self.assertIn("Diff successfully applied", result)
1153
1183
  with open(path) as f:
1154
1184
  self.assertEqual(f.read(), "a\nB\nc\n")
@@ -1163,7 +1193,7 @@ class TestEditDiffModePatch(unittest.TestCase):
1163
1193
  f.write("a\n--removed\nzzz\nc\n")
1164
1194
  ctx, _ = make_ctx()
1165
1195
  diff = "--- a/f.txt\n+++ b/f.txt\n@@ -1,9 +1,9 @@\n a\n---removed\n+++added\n c\n"
1166
- result = Edit().run({"path": path, "new_str": diff, "diff": True}, ctx)
1196
+ result = edit_tool().run({"path": path, "new_str": diff, "diff": True}, ctx)
1167
1197
  self.assertIn("Diff successfully applied", result)
1168
1198
  with open(path) as f:
1169
1199
  self.assertEqual(f.read(), "a\n++added\nzzz\nc\n")
@@ -1182,7 +1212,7 @@ class TestEditDiffModePatch(unittest.TestCase):
1182
1212
  "@@ -2,3 +2,2 @@\n id INT\n );\n--- old comment\n"
1183
1213
  "@@ -6,1 +6,1 @@\n--- tail\n+-- new tail\n"
1184
1214
  )
1185
- result = Edit().run({"path": path, "new_str": diff, "diff": True}, ctx)
1215
+ result = edit_tool().run({"path": path, "new_str": diff, "diff": True}, ctx)
1186
1216
  self.assertIn("Diff successfully applied", result)
1187
1217
  with open(path) as f:
1188
1218
  self.assertEqual(
@@ -1204,7 +1234,7 @@ class TestEditDiffModePatch(unittest.TestCase):
1204
1234
  "--- a/f2.txt\n+++ b/f2.txt\n@@ -1 +1 @@\n-two\n+TWO\n"
1205
1235
  )
1206
1236
  # Trailing slash -> patch runs inside the directory.
1207
- result = Edit().run({"path": d + os.sep, "new_str": diff, "diff": True}, ctx)
1237
+ result = edit_tool().run({"path": d + os.sep, "new_str": diff, "diff": True}, ctx)
1208
1238
  self.assertIn("Diff successfully applied", result)
1209
1239
  with open(os.path.join(d, "f1.txt")) as f:
1210
1240
  self.assertEqual(f.read(), "ONE\n")
@@ -1212,5 +1242,141 @@ class TestEditDiffModePatch(unittest.TestCase):
1212
1242
  self.assertEqual(f.read(), "TWO\n")
1213
1243
 
1214
1244
 
1245
+ class TestEditMac(unittest.TestCase):
1246
+ """The macOS Edit backend (pure-Python diff applier) must accept the
1247
+ same model diffs that GNU patch accepts on Linux. Run on every
1248
+ platform so the macOS path is covered by Linux CI too."""
1249
+
1250
+ def _file(self, d: str, name: str, content: str) -> str:
1251
+ path = os.path.join(d, name)
1252
+ with open(path, "w") as f:
1253
+ f.write(content)
1254
+ return path
1255
+
1256
+ def test_dashed_content_line_ending_a_hunk_applies(self):
1257
+ """The macOS CI failure: a removed line starting with -- as the
1258
+ LAST body line of a hunk, followed by a second hunk. Apple's
1259
+ BSD patch rejects it; the Python applier must apply it."""
1260
+ with tempfile.TemporaryDirectory() as d:
1261
+ path = self._file(
1262
+ d,
1263
+ "schema.sql",
1264
+ "CREATE TABLE t (\n id INT\n);\n-- old comment\nSELECT 1;\n-- tail\n",
1265
+ )
1266
+ ctx, sess = make_ctx()
1267
+ diff = (
1268
+ "--- a/schema.sql\n+++ b/schema.sql\n"
1269
+ "@@ -2,3 +2,2 @@\n id INT\n );\n--- old comment\n"
1270
+ "@@ -6,1 +6,1 @@\n--- tail\n+-- new tail\n"
1271
+ )
1272
+ result = EditMac().run({"path": path, "new_str": diff, "diff": True}, ctx)
1273
+ self.assertIn("Diff successfully applied", result)
1274
+ with open(path) as f:
1275
+ self.assertEqual(
1276
+ f.read(), "CREATE TABLE t (\n id INT\n);\nSELECT 1;\n-- new tail\n"
1277
+ )
1278
+ self.assertEqual(len(sess.recorded_diffs), 1)
1279
+
1280
+ def test_simple_replace_applies(self):
1281
+ with tempfile.TemporaryDirectory() as d:
1282
+ path = self._file(d, "f.txt", "line1\nline2\nline3\n")
1283
+ ctx, sess = make_ctx()
1284
+ diff = "--- a/f.txt\n+++ b/f.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+lineTWO\n line3\n"
1285
+ result = EditMac().run({"path": path, "new_str": diff, "diff": True}, ctx)
1286
+ self.assertIn("Diff successfully applied", result)
1287
+ with open(path) as f:
1288
+ self.assertEqual(f.read(), "line1\nlineTWO\nline3\n")
1289
+ self.assertEqual(len(sess.recorded_diffs), 1)
1290
+
1291
+ def test_wrong_hunk_counts_recounted(self):
1292
+ with tempfile.TemporaryDirectory() as d:
1293
+ path = self._file(d, "f.txt", "a\nb\nc\n")
1294
+ ctx, _ = make_ctx()
1295
+ diff = "--- a/f.txt\n+++ b/f.txt\n@@ -1,9 +1,9 @@\n a\n-b\n+B\n c\n"
1296
+ result = EditMac().run({"path": path, "new_str": diff, "diff": True}, ctx)
1297
+ self.assertIn("Diff successfully applied", result)
1298
+ with open(path) as f:
1299
+ self.assertEqual(f.read(), "a\nB\nc\n")
1300
+
1301
+ def test_fenced_diff_applies(self):
1302
+ with tempfile.TemporaryDirectory() as d:
1303
+ path = self._file(d, "f.txt", "a\nb\nc\n")
1304
+ ctx, _ = make_ctx()
1305
+ diff = "```diff\n--- a/f.txt\n+++ b/f.txt\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n```\n"
1306
+ result = EditMac().run({"path": path, "new_str": diff, "diff": True}, ctx)
1307
+ self.assertIn("Diff successfully applied", result)
1308
+ with open(path) as f:
1309
+ self.assertEqual(f.read(), "a\nB\nc\n")
1310
+
1311
+ def test_dashed_content_lines_apply(self):
1312
+ with tempfile.TemporaryDirectory() as d:
1313
+ path = self._file(d, "f.txt", "a\n--removed\nzzz\nc\n")
1314
+ ctx, _ = make_ctx()
1315
+ diff = "--- a/f.txt\n+++ b/f.txt\n@@ -1,9 +1,9 @@\n a\n---removed\n+++added\n c\n"
1316
+ result = EditMac().run({"path": path, "new_str": diff, "diff": True}, ctx)
1317
+ self.assertIn("Diff successfully applied", result)
1318
+ with open(path) as f:
1319
+ self.assertEqual(f.read(), "a\n++added\nzzz\nc\n")
1320
+
1321
+ def test_mismatch_errors_and_leaves_file_untouched(self):
1322
+ with tempfile.TemporaryDirectory() as d:
1323
+ path = self._file(d, "f.txt", "line1\nline2\nline3\n")
1324
+ ctx, sess = make_ctx()
1325
+ bad_diff = "--- a/f.txt\n+++ b/f.txt\n@@ -1,2 +1,2 @@\n line1\n-NOPE\n+lineTWO\n"
1326
+ result = EditMac().run({"path": path, "new_str": bad_diff, "diff": True}, ctx)
1327
+ self.assertTrue(result.startswith("Error:"))
1328
+ with open(path) as f:
1329
+ self.assertEqual(f.read(), "line1\nline2\nline3\n")
1330
+ self.assertEqual(sess.recorded_diffs, [])
1331
+
1332
+ def test_directory_multifile_diff_applies(self):
1333
+ with tempfile.TemporaryDirectory() as d:
1334
+ self._file(d, "f1.txt", "one\n")
1335
+ self._file(d, "f2.txt", "two\n")
1336
+ ctx, _ = make_ctx()
1337
+ diff = (
1338
+ "--- a/f1.txt\n+++ b/f1.txt\n@@ -1 +1 @@\n-one\n+ONE\n"
1339
+ "--- a/f2.txt\n+++ b/f2.txt\n@@ -1 +1 @@\n-two\n+TWO\n"
1340
+ )
1341
+ result = EditMac().run({"path": d + os.sep, "new_str": diff, "diff": True}, ctx)
1342
+ self.assertIn("Diff successfully applied", result)
1343
+ with open(os.path.join(d, "f1.txt")) as f:
1344
+ self.assertEqual(f.read(), "ONE\n")
1345
+ with open(os.path.join(d, "f2.txt")) as f:
1346
+ self.assertEqual(f.read(), "TWO\n")
1347
+
1348
+ def test_insert_only_hunk(self):
1349
+ with tempfile.TemporaryDirectory() as d:
1350
+ path = self._file(d, "f.txt", "a\nc\n")
1351
+ ctx, _ = make_ctx()
1352
+ diff = "--- a/f.txt\n+++ b/f.txt\n@@ -1,1 +1,2 @@\n a\n+b\n c\n"
1353
+ result = EditMac().run({"path": path, "new_str": diff, "diff": True}, ctx)
1354
+ self.assertIn("Diff successfully applied", result)
1355
+ with open(path) as f:
1356
+ self.assertEqual(f.read(), "a\nb\nc\n")
1357
+
1358
+ def test_fuzz_matches_context_one_line_off(self):
1359
+ """A context line that does not match (model drift) still
1360
+ applies when the removed lines match, up to the fuzz limit."""
1361
+ with tempfile.TemporaryDirectory() as d:
1362
+ path = self._file(d, "f.txt", "x\na\nzzz\nc\n")
1363
+ ctx, _ = make_ctx()
1364
+ diff = "--- a/f.txt\n+++ b/f.txt\n@@ -1,3 +1,3 @@\n a\n-zzz\n+c\n"
1365
+ result = EditMac().run({"path": path, "new_str": diff, "diff": True}, ctx)
1366
+ self.assertIn("Diff successfully applied", result)
1367
+ with open(path) as f:
1368
+ self.assertEqual(f.read(), "x\na\nc\nc\n")
1369
+
1370
+ def test_no_newline_at_eof_handled(self):
1371
+ with tempfile.TemporaryDirectory() as d:
1372
+ path = self._file(d, "f.txt", "a\nb")
1373
+ ctx, _ = make_ctx()
1374
+ diff = "--- a/f.txt\n+++ b/f.txt\n@@ -1,2 +1,2 @@\n a\n-b\n+b\n\\n"
1375
+ result = EditMac().run({"path": path, "new_str": diff, "diff": True}, ctx)
1376
+ self.assertIn("Diff successfully applied", result)
1377
+ with open(path) as f:
1378
+ self.assertEqual(f.read(), "a\nb")
1379
+
1380
+
1215
1381
  if __name__ == "__main__":
1216
1382
  unittest.main()
@@ -780,6 +780,32 @@ class TestPlanModeWriteGuard(unittest.TestCase):
780
780
  with open(plan_file) as f:
781
781
  self.assertEqual(f.read(), "via link")
782
782
 
783
+ def test_plan_file_writable_when_temp_dir_is_symlinked(self):
784
+ """Regression: when the plan dir goes through a symlink (macOS
785
+ TMPDIR /var -> /private/var), the plan file must still be
786
+ recognized as the plan file and stay writable in plan mode."""
787
+ with tempfile.TemporaryDirectory(prefix="pah-prop-plan-") as tmpdir:
788
+ real = os.path.join(tmpdir, "real")
789
+ os.makedirs(real)
790
+ link = os.path.join(tmpdir, "link")
791
+ os.symlink(real, link)
792
+ with mock.patch(
793
+ "python_agent_harness.planmode._plan_temp_dir",
794
+ return_value=link,
795
+ ):
796
+ session = self.make_plan_session(tmpdir)
797
+ plan_file = session.plan_mode.plan_file
798
+ self.assertTrue(os.path.isfile(plan_file))
799
+ dirname, basename = os.path.split(plan_file)
800
+ r = session.execute_tool(
801
+ "Write",
802
+ {"path": dirname, "filename": basename, "content": "# plan\n"},
803
+ call_id="w1",
804
+ )
805
+ self.assertNotIn("blocked by plan mode", r)
806
+ with open(plan_file) as f:
807
+ self.assertEqual(f.read(), "# plan\n")
808
+
783
809
  def test_read_only_tools_pass_through_plan_mode(self):
784
810
  with tempfile.TemporaryDirectory(prefix="pah-prop-plan-") as tmpdir:
785
811
  session = self.make_plan_session(tmpdir)
@@ -200,7 +200,7 @@ class TestIndexSkills(unittest.TestCase):
200
200
  encoding="utf-8",
201
201
  )
202
202
  index = index_skills(d)
203
- self.assertEqual(index, {"天气预报助手": (str(skill), "does things")})
203
+ self.assertEqual(index, {"天气预报助手": (os.path.realpath(skill), "does things")})
204
204
 
205
205
  def test_no_frontmatter_name_skipped(self):
206
206
  with tempfile.TemporaryDirectory() as d:
@@ -219,7 +219,7 @@ class TestIndexSkills(unittest.TestCase):
219
219
  skill = sub / "SKILL.md"
220
220
  skill.write_text("---\nname: linked-skill\n---\nbody", encoding="utf-8")
221
221
  os.symlink(sub, Path(d) / "linked")
222
- self.assertEqual(index_skills(d)["linked-skill"][0], str(skill))
222
+ self.assertEqual(index_skills(d)["linked-skill"][0], os.path.realpath(skill))
223
223
 
224
224
 
225
225
  class TestLoadContextFiles(unittest.TestCase):
@@ -152,7 +152,10 @@ class TestFindSkill(unittest.TestCase):
152
152
  with open(os.path.join(sub, "SKILL.md"), "w") as f:
153
153
  f.write("---\nname: my-rules\ndescription: rules\n---\n# Rules")
154
154
  session._skill_dir = d
155
- self.assertEqual(session.find_skill("my-rules"), os.path.join(sub, "SKILL.md"))
155
+ self.assertEqual(
156
+ session.find_skill("my-rules"),
157
+ os.path.realpath(os.path.join(sub, "SKILL.md")),
158
+ )
156
159
 
157
160
  def test_frontmatter_name_differs_from_directory_name(self):
158
161
  """Regression test: the advertised name (frontmatter) must
@@ -165,7 +168,10 @@ class TestFindSkill(unittest.TestCase):
165
168
  with open(os.path.join(sub, "SKILL.md"), "w") as f:
166
169
  f.write("---\nname: 天气预报助手\n---\n# body")
167
170
  session._skill_dir = d
168
- self.assertEqual(session.find_skill("天气预报助手"), os.path.join(sub, "SKILL.md"))
171
+ self.assertEqual(
172
+ session.find_skill("天气预报助手"),
173
+ os.path.realpath(os.path.join(sub, "SKILL.md")),
174
+ )
169
175
 
170
176
  def test_nested_skill_dir_discovered(self):
171
177
  session = RecordingSession()
@@ -175,7 +181,10 @@ class TestFindSkill(unittest.TestCase):
175
181
  with open(os.path.join(nested, "SKILL.md"), "w") as f:
176
182
  f.write("---\nname: deep-skill\n---\nbody")
177
183
  session._skill_dir = d
178
- self.assertEqual(session.find_skill("deep-skill"), os.path.join(nested, "SKILL.md"))
184
+ self.assertEqual(
185
+ session.find_skill("deep-skill"),
186
+ os.path.realpath(os.path.join(nested, "SKILL.md")),
187
+ )
179
188
 
180
189
  def test_flat_file_without_frontmatter_returns_none(self):
181
190
  """Flat files (skills/style.md) are no longer resolvable: only
@@ -210,7 +219,10 @@ class TestFindSkill(unittest.TestCase):
210
219
  f.write("---\nname: linked-skill\n---\n# Linked")
211
220
  os.symlink(sub, os.path.join(d, "linked"))
212
221
  session._skill_dir = d
213
- self.assertEqual(session.find_skill("linked-skill"), os.path.join(sub, "SKILL.md"))
222
+ self.assertEqual(
223
+ session.find_skill("linked-skill"),
224
+ os.path.realpath(os.path.join(sub, "SKILL.md")),
225
+ )
214
226
 
215
227
  def test_traversal_skill_returns_none(self):
216
228
  """Path-traversal inputs are inert: lookup only matches names in