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
18 changes: 18 additions & 0 deletions Sources/SwiftAgentKitTools/FileSystemTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ public struct PatchFileTool: AgentTool {
public let name = "apply_patch"
public let description = """
Edit an existing file by applying a unified diff (git / `diff -u` format). \
One file per call: `path` is that file; to change two files, call it twice. \
Provide the smallest diff that makes the change — one or more `@@` hunks with \
a few lines of surrounding context; `-` lines are removed, `+` lines added. \
Line numbers in `@@` headers may be approximate (matched by context, tolerant \
Expand Down Expand Up @@ -238,6 +239,23 @@ public struct PatchFileTool: AgentTool {
if let policy, let reason = policy.blockReason(for: path) {
return .error(toolCallId: "", toolName: name, message: "Refusing to patch \(raw): \(reason).")
}
// One file per call. A diff spanning two files had all its hunks tried
// against `path`, and the second file's hunk "didn't match" (Naseem,
// 2026-10-09: PomoApp.swift + project.pbxproj in one patch).
let targets = UnifiedDiff.targets(in: patch)
if targets.count > 1 {
return .error(toolCallId: "", toolName: name, message: """
This patch changes \(targets.count) files (\(targets.joined(separator: ", "))); apply_patch takes \
one file per call. Nothing was changed. Send one apply_patch per file, each with that file's `path`.
""")
}
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue {
return .error(toolCallId: "", toolName: name, message: """
\(raw) is a folder. `path` must be the file the diff changes\
\(targets.first.map { " (this diff names \($0))" } ?? ""). Nothing was changed.
""")
}
guard let data = FileManager.default.contents(atPath: path) else {
return .error(toolCallId: "", toolName: name,
message: "Cannot read file to patch: \(raw). Use write_file to create a new file.")
Expand Down
16 changes: 16 additions & 0 deletions Sources/SwiftAgentKitTools/UnifiedDiff.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ enum UnifiedDiff {
return hunks.isEmpty ? nil : hunks
}

/// The files a diff changes, from its `+++` headers (a leading `a/`/`b/`
/// dropped, /dev/null skipped), in order and distinct. Empty when the diff
/// has no headers — the tool's `path` names its one file.
static func targets(in patch: String) -> [String] {
var out: [String] = []
for raw in patch.components(separatedBy: "\n") where raw.hasPrefix("+++ ") {
var name = String(raw.dropFirst(4))
if let tab = name.firstIndex(of: "\t") { name = String(name[..<tab]) }
name = name.trimmingCharacters(in: .whitespaces)
if name == "/dev/null" || name.isEmpty { continue }
if name.hasPrefix("a/") || name.hasPrefix("b/") { name.removeFirst(2) }
if !out.contains(name) { out.append(name) }
}
return out
}

/// Extract the old-file start line from an `@@ -a,b +c,d @@` header.
private static func parseOldStart(_ header: String) -> Int? {
guard let dash = header.firstIndex(of: "-") else { return nil }
Expand Down
46 changes: 46 additions & 0 deletions Tests/SwiftAgentKitToolsTests/UnifiedDiffTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,50 @@ struct UnifiedDiffTests {
#expect(nearby.contains("line10"))
#expect(nearby.contains("10 |")) // numbered, so the model can re-anchor
}

// MARK: - One file per call (Naseem, 2026-10-09)

/// A model put PomoApp.swift's and project.pbxproj's hunks in one patch
/// with path=PomoApp.swift; the pbxproj hunk "didn't match" the Swift file.
@Test func targetsListsEachFileTheDiffChanges() {
let patch = """
--- a/Pomo/PomoApp.swift
+++ b/Pomo/PomoApp.swift
@@ -1,1 +1,1 @@
-a
+b
--- a/Pomo.xcodeproj/project.pbxproj
+++ b/Pomo.xcodeproj/project.pbxproj
@@ -1,1 +1,1 @@
-c
+d
"""
#expect(UnifiedDiff.targets(in: patch) == ["Pomo/PomoApp.swift", "Pomo.xcodeproj/project.pbxproj"])
#expect(UnifiedDiff.targets(in: "--- a/f.txt\n+++ b/f.txt\n@@ -1 +1 @@\n-a\n+b") == ["f.txt"])
#expect(UnifiedDiff.targets(in: "@@ -1 +1 @@\n-a\n+b").isEmpty)
}

@Test func aMultiFilePatchIsRefusedWithBothNames() async throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("sak-patch-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let file = dir.appendingPathComponent("a.txt")
try "a\n".write(to: file, atomically: true, encoding: .utf8)
let patch = "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-a\n+A\n--- a/b.txt\n+++ b/b.txt\n@@ -1 +1 @@\n-b\n+B"
let r = try await PatchFileTool().execute(parameters: ["path": file.path, "patch": patch])
#expect(r.isError)
#expect(r.result.contains("a.txt") && r.result.contains("b.txt"))
#expect(r.result.contains("one file per call"))
#expect(try String(contentsOf: file, encoding: .utf8) == "a\n")
}

@Test func aFolderPathIsNamedAsAFolder() async throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("sak-patch-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let r = try await PatchFileTool().execute(parameters: ["path": dir.path, "patch": "@@ -1 +1 @@\n-a\n+b"])
#expect(r.isError)
#expect(r.result.contains("is a folder"))
#expect(!r.result.contains("Use write_file"))
}
}
Loading