Editing a config on a host cost two full copies of it in tokens. `sftp_read` returns the file inline, and `sftp_write` takes the replacement back as a `content` string the model has to re-emit in full — so the ordinary "pull it, change three lines, push it back" job paid for the whole file twice, and anything over the 128 KB inline cap, or anything that was not UTF-8, was out of reach entirely. There was also nowhere to keep a draft between turns: a script the agent was composing lived only in the transcript, which the context pipeline trims head-and-tail as the run grows. `AgentScratchWorkspace` is a per-conversation staging directory under Application Support, inside the sandbox container, deleted with the conversation that owns it. Application Support rather than Caches because the system may evict a Caches directory mid-run, and a staging area that can vanish between two tool calls is worse than none. Paths are relative; `..` is refused rather than resolved, which is the inverse of `normalizeRemotePath` and the right inversion — a relative path with `..` in it is never something the agent needed. Symlinks are refused even when they point back inside, because an alias within the workspace makes two paths the same file and quietly breaks both the quota arithmetic and the atomicity `apply(_:to:)` claims. Every resolved path is then proven to still be under the scope. Writes are bounded per file, per conversation, and by entry count, and hidden files are counted — a quota you can evade by naming a file `.big` is not a quota. It is an actor, and that is load-bearing rather than incidental. Pi serializes file mutations through an explicit per-path queue; here the actor already serializes every operation against every other, so a read-modify-write is atomic for free. That is what lets every local scratch tool stay `.parallel`: a batch containing one does not have to drag its whole turn serial to be safe. `scratch_edit` follows Pi's `edit` and is what makes the rest pay off. Every `old_text` is matched against the file as it was before the call, not against the result of the preceding edit, so the model never simulates its own changes to write the second one. Matches must be unique and non-overlapping; anything ambiguous fails the whole call rather than applying part of it, because a half-applied edit is the one outcome nobody can reason about. Matching is exact first, then one line-aligned retry that ignores smart quotes, unicode dashes and trailing whitespace — without it an edit fails whenever the model retypes a block with tidied punctuation, which is often. And it returns a unified diff rather than the new file, which is the entire point: an 8 MB file can now be changed for the price of the lines that changed. `scratch_pull` and `scratch_push` move the bytes, and are separate tools rather than options on `sftp_read` / `sftp_write`. A tool that means two different things depending on which optional argument is present is harder for a model to choose correctly and harder to audit, and it would have put two safety levels inside one descriptor. The SFTP tools are untouched. `Safety` gains `locallyContained`. The existing two cases state *why* a call may skip the gate, and "read-only" is the wrong reason for a scratch write — it changes something, it just cannot change anything outside storage the app owns. It is auto-allowed in every mode including Ask for approval, because a staging area you must approve into is not a staging area, and six dialogs for one edit loop teach the reader to click through the seventh, which is the one that reaches a host. `isAutoAllowed` is a property of the case rather than an `== .requiresAuthorization` at the call site, so a fourth case has to make a decision here instead of defaulting to allowed. Nothing switches exhaustively over `Safety` and it is not persisted, so there is no migration. `scratch_push` is the only gated tool here, and it has a problem the SFTP tools do not: the approval card would show a filename where it used to show the bytes, leaving the reviewer — human or model — agreeing to something neither had seen. Its preflight therefore reads the staged file and puts the size, digest and a redacted twenty-line head into `localReasons`, which both the card and Security Review's `Local signals:` line already render. It also carries the digest in `executionMetadata` and rechecks it at execute time: approval suspends the turn, and what comes back from the workspace afterwards is only the same file if nothing rewrote it in between. The schema helpers move to an `AgentToolSchemaBuilding` protocol with defaults rather than a namespace enum, so a conforming type still calls `descriptor(…)` and `string(max:)` unqualified and a second tool file costs a conformance instead of a copy of forty lines. `UnifiedDiff` is a formatter over `CollectionDifference` — Swift's `difference(from:)` is already a Myers diff, so no dependency. It trims the common prefix and suffix before running, because Myers is fast on similar inputs and quadratic on unrelated ones, and refuses anything past a line limit in what is left. Its output was checked against the real tool before any expectation was written down: for eight cases it is byte-identical to GNU `diff -u`, and `patch -p1` applies each one and reproduces the target file. That found three bugs reading the code had not. `firstChangedLine` was nil for every replacement, because the first changed row of one is the removal and a removal has no line number on the new side. A trailing-newline-only difference produced an empty diff with `identical: false` — true and useless. And `LineEnding.detected` reported `.lf` for every CRLF file, because Swift treats "\r\n" as a single Character and `firstIndex(of: "\n")` finds nothing in a Windows file; editing one line would have rewritten every line ending in it. A fourth came from the BOM test: `String(data:encoding:.utf8)` strips a UTF-8 BOM while decoding, so checking the decoded String never fired and writing it back dropped those three bytes. It now reads the raw Data, and the test asserts on bytes for the same reason — decoding would have reported a pass either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
196 lines
7.1 KiB
Swift
196 lines
7.1 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import Termind
|
|
|
|
/// Every expectation below was checked against GNU `diff -u` before it was written
|
|
/// down: for each case the generated text is byte-identical to `diff -u --label a/f
|
|
/// --label b/f`, and `patch -p1` applies it to the old file and reproduces the new
|
|
/// one exactly. Asserting the literal output is therefore asserting conformance to
|
|
/// the real format rather than to whatever this implementation happened to emit.
|
|
@Suite
|
|
struct UnifiedDiffTests {
|
|
|
|
// MARK: - Line endings
|
|
|
|
@Test
|
|
func lineEndingIsWhicheverComesFirstAndSurvivesARoundTrip() {
|
|
#expect(LineEnding.detected(in: "a\nb\n") == .lf)
|
|
#expect(LineEnding.detected(in: "a\r\nb\r\n") == .crlf)
|
|
#expect(LineEnding.detected(in: "no newline at all") == .lf)
|
|
// Mixed reports what it leads with, and normalizing to that is the only
|
|
// answer that leaves the file more consistent than it found it.
|
|
#expect(LineEnding.detected(in: "a\r\nb\nc") == .crlf)
|
|
|
|
#expect(LineEnding.normalizedToLF("a\r\nb\rc\n") == "a\nb\nc\n")
|
|
#expect(LineEnding.crlf.restore("a\nb\n") == "a\r\nb\r\n")
|
|
#expect(LineEnding.lf.restore("a\nb\n") == "a\nb\n")
|
|
}
|
|
|
|
// MARK: - Identity
|
|
|
|
@Test
|
|
func identicalTextsProduceNoHunk() throws {
|
|
let diff = try UnifiedDiff.between("a\nb\n", "a\nb\n", fromPath: "f", toPath: "f")
|
|
#expect(diff.isIdentical)
|
|
#expect(diff.text.isEmpty)
|
|
#expect(diff.firstChangedLine == nil)
|
|
}
|
|
|
|
// MARK: - Shapes
|
|
|
|
@Test
|
|
func replacementPutsRemovalsAboveInsertions() throws {
|
|
let diff = try UnifiedDiff.between(
|
|
"a\nb\nc\nd\ne\nf\ng\n", "a\nb\nc\nX\ne\nf\ng\n", fromPath: "f", toPath: "f"
|
|
)
|
|
#expect(diff.text == """
|
|
--- a/f
|
|
+++ b/f
|
|
@@ -1,7 +1,7 @@
|
|
a
|
|
b
|
|
c
|
|
-d
|
|
+X
|
|
e
|
|
f
|
|
g
|
|
|
|
""")
|
|
// The regression this pins: the first changed row of a replacement is the
|
|
// removal, which has no line number on the new side. Reading the field
|
|
// directly reported nothing at all for the commonest kind of edit.
|
|
#expect(diff.firstChangedLine == 4)
|
|
}
|
|
|
|
@Test
|
|
func insertionAndDeletionCountTheirSidesCorrectly() throws {
|
|
let inserted = try UnifiedDiff.between(
|
|
"a\nb\nc\n", "a\nb\nNEW\nc\n", fromPath: "f", toPath: "f"
|
|
)
|
|
#expect(inserted.text.contains("@@ -1,3 +1,4 @@"))
|
|
#expect(inserted.firstChangedLine == 3)
|
|
|
|
let deleted = try UnifiedDiff.between("a\nb\nc\n", "a\nc\n", fromPath: "f", toPath: "f")
|
|
#expect(deleted.text.contains("@@ -1,3 +1,2 @@"))
|
|
// Where the divergence now stands in the new file, not where it was.
|
|
#expect(deleted.firstChangedLine == 2)
|
|
}
|
|
|
|
/// `git diff` writes a one-line range without its count, and so does this.
|
|
@Test
|
|
func aSingleLineRangeOmitsItsCount() throws {
|
|
let diff = try UnifiedDiff.between("a\n", "a\nb\n", fromPath: "f", toPath: "f")
|
|
#expect(diff.text.contains("@@ -1 +1,2 @@"))
|
|
}
|
|
|
|
@Test
|
|
func distantChangesBecomeSeparateHunksAtTheRightOffsets() throws {
|
|
let old = (1...30).map { "L\($0)" }.joined(separator: "\n") + "\n"
|
|
let new = (1...30).map { line -> String in
|
|
switch line {
|
|
case 3: "L3-changed"
|
|
case 25: "L25-changed"
|
|
default: "L\(line)"
|
|
}
|
|
}.joined(separator: "\n") + "\n"
|
|
|
|
let diff = try UnifiedDiff.between(old, new, fromPath: "f", toPath: "f")
|
|
#expect(diff.text.contains("@@ -1,6 +1,6 @@"))
|
|
#expect(diff.text.contains("@@ -22,7 +22,7 @@"))
|
|
#expect(diff.text.components(separatedBy: "@@ -").count == 3)
|
|
#expect(diff.firstChangedLine == 3)
|
|
}
|
|
|
|
@Test
|
|
func nearbyChangesMergeIntoOneHunk() throws {
|
|
let old = (1...20).map { "L\($0)" }.joined(separator: "\n") + "\n"
|
|
let new = (1...20).map { $0 == 8 || $0 == 11 ? "L\($0)-changed" : "L\($0)" }
|
|
.joined(separator: "\n") + "\n"
|
|
let diff = try UnifiedDiff.between(old, new, fromPath: "f", toPath: "f")
|
|
// Two changes three lines apart share their context rather than printing it
|
|
// twice with a redundant header between.
|
|
#expect(diff.text.components(separatedBy: "@@ -").count == 2)
|
|
}
|
|
|
|
// MARK: - Trailing newline
|
|
|
|
/// The one difference that changes no line. Reporting "not identical" with an
|
|
/// empty diff was true and useless.
|
|
@Test
|
|
func aMissingTrailingNewlineIsShownRatherThanImplied() throws {
|
|
let diff = try UnifiedDiff.between("a\nb\n", "a\nb", fromPath: "f", toPath: "f")
|
|
#expect(!diff.isIdentical)
|
|
#expect(diff.text == """
|
|
--- a/f
|
|
+++ b/f
|
|
@@ -1,2 +1,2 @@
|
|
a
|
|
-b
|
|
+b
|
|
\\ No newline at end of file
|
|
|
|
""")
|
|
#expect(diff.firstChangedLine == 2)
|
|
}
|
|
|
|
@Test
|
|
func theMarkerAppearsOnWhicheverSidesLackTheNewline() throws {
|
|
let diff = try UnifiedDiff.between("a\nb\nc", "a\nb\nX", fromPath: "f", toPath: "f")
|
|
#expect(diff.text == """
|
|
--- a/f
|
|
+++ b/f
|
|
@@ -1,3 +1,3 @@
|
|
a
|
|
b
|
|
-c
|
|
\\ No newline at end of file
|
|
+X
|
|
\\ No newline at end of file
|
|
|
|
""")
|
|
}
|
|
|
|
// MARK: - Bounds
|
|
|
|
@Test
|
|
func outputPastTheByteLimitIsTruncatedAtAHunkBoundary() throws {
|
|
let old = (1...400).map { "line \($0)" }.joined(separator: "\n") + "\n"
|
|
let new = (1...400).map { $0 % 10 == 0 ? "line \($0) changed" : "line \($0)" }
|
|
.joined(separator: "\n") + "\n"
|
|
let diff = try UnifiedDiff.between(
|
|
old, new, fromPath: "f", toPath: "f", maximumBytes: 512
|
|
)
|
|
#expect(diff.isTruncated)
|
|
#expect(diff.text.utf8.count <= 512)
|
|
// Still a diff, not a fragment ending mid-hunk.
|
|
#expect(diff.text.hasPrefix("--- a/f\n+++ b/f\n"))
|
|
#expect(!diff.text.hasSuffix("@@"))
|
|
}
|
|
|
|
/// Myers is quadratic on two texts that share nothing, so the guard is what
|
|
/// keeps a diff of unrelated files from hanging the run.
|
|
@Test
|
|
func aDifferingRegionPastTheLineLimitIsRefused() {
|
|
let old = (1...50).map { "old \($0)" }.joined(separator: "\n")
|
|
let new = (1...50).map { "new \($0)" }.joined(separator: "\n")
|
|
#expect(throws: UnifiedDiff.Failure.tooLarge(lines: 50, limit: 10)) {
|
|
try UnifiedDiff.between(old, new, fromPath: "f", toPath: "f", lineLimit: 10)
|
|
}
|
|
}
|
|
|
|
/// The limit applies to what actually differs, not to file size — which is what
|
|
/// makes editing a large file affordable.
|
|
@Test
|
|
func aLargeFileWithASmallChangeIsNotRefused() throws {
|
|
let old = (1...5_000).map { "line \($0)" }.joined(separator: "\n") + "\n"
|
|
let new = old.replacingOccurrences(of: "line 2500\n", with: "line 2500 changed\n")
|
|
let diff = try UnifiedDiff.between(
|
|
old, new, fromPath: "f", toPath: "f", lineLimit: 50
|
|
)
|
|
#expect(diff.firstChangedLine == 2_500)
|
|
#expect(diff.text.contains("+line 2500 changed"))
|
|
}
|
|
}
|