Files
openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolDiffTests.swift
Peter Steinberger 1ef99e6dfb feat(agents): authoritative write diffs end to end (#111456)
* feat(agents): authoritative write diffs end to end

The write tool now reports what actually happened: no-op (changed:false),
created files (created:true with a bounded numbered diff), and overwrites
(created:false with a real old-to-new diff when the old content is
readable text within byte, line, and edit-distance budgets — unknown
removals are never fabricated). The gateway history projection forwards
the changed/created flags, the web and iOS write rows prefer the
authoritative diff and suppress the +N -0 stat when creation is not
proven, and the iOS transcript cache persists one bounded apply_patch
envelope so patch diffs survive cold opens.

* chore(i18n): sync native inventory
2026-07-19 08:48:26 -07:00

440 lines
18 KiB
Swift

import Foundation
import OpenClawKit
import Testing
@testable import OpenClawChatUI
@Suite("ChatToolDiff")
struct ChatToolDiffTests {
private func parsedLines(_ diff: String) -> [ChatToolDiffLine]? {
ChatToolDiff.resolveDiff(
name: "edit",
arguments: nil,
details: AnyCodable(["diff": AnyCodable(diff)]))?.lines
}
@Test func `parses numbered details diffs`() {
let parsed = self.parsedLines(" 455 before\n-456 old\n+456 new")
#expect(parsed == [
ChatToolDiffLine(kind: .ctx, lineNo: 455, text: "before"),
ChatToolDiffLine(kind: .del, lineNo: 456, text: "old"),
ChatToolDiffLine(kind: .add, lineNo: 456, text: "new"),
])
}
@Test func `parses skip markers`() {
let cases = ["+1 kept\n...", "+1 kept\n...(truncated)..."]
for diff in cases {
#expect(self.parsedLines(diff) == [
ChatToolDiffLine(kind: .add, lineNo: 1, text: "kept"),
ChatToolDiffLine(kind: .skip, text: ""),
])
}
}
@Test func `rejects malformed or unchanged details`() {
for diff in ["raw line", " 1 context\n 2 context"] {
#expect(self.parsedLines(diff) == nil)
}
}
@Test func `computes basic line diffs`() {
let cases: [(String, String, [ChatToolDiffLine])] = [
("before", "after", [
ChatToolDiffLine(kind: .del, text: "before"),
ChatToolDiffLine(kind: .add, text: "after"),
]),
("same", "same\nadded", [
ChatToolDiffLine(kind: .ctx, text: "same"),
ChatToolDiffLine(kind: .add, text: "added"),
]),
("same\nremoved", "same", [
ChatToolDiffLine(kind: .ctx, text: "same"),
ChatToolDiffLine(kind: .del, text: "removed"),
]),
("", "written", [ChatToolDiffLine(kind: .add, text: "written")]),
]
for (old, new, expected) in cases {
#expect(ChatToolDiff.computeLineDiff(old: old, new: new) == expected)
}
}
@Test func `trailing newline is not an extra line`() {
#expect(ChatToolDiff.computeLineDiff(old: "", new: "foo\n") == [
ChatToolDiffLine(kind: .add, text: "foo"),
])
}
@Test func `oversized input degrades to a skip`() {
let text = (0...600).map { "line \($0)" }.joined(separator: "\n")
#expect(ChatToolDiff.computeLineDiff(old: text, new: text) == [
ChatToolDiffLine(kind: .skip, text: ""),
])
}
@Test func `compaction keeps three context lines around a change`() {
let oldLines = (0..<500).map { "line \($0)" }
var newLines = oldLines
newLines[250] = "changed"
let expected = [
ChatToolDiffLine(kind: .skip, text: ""),
ChatToolDiffLine(kind: .ctx, text: "line 247"),
ChatToolDiffLine(kind: .ctx, text: "line 248"),
ChatToolDiffLine(kind: .ctx, text: "line 249"),
ChatToolDiffLine(kind: .del, text: "line 250"),
ChatToolDiffLine(kind: .add, text: "changed"),
ChatToolDiffLine(kind: .ctx, text: "line 251"),
ChatToolDiffLine(kind: .ctx, text: "line 252"),
ChatToolDiffLine(kind: .ctx, text: "line 253"),
ChatToolDiffLine(kind: .skip, text: ""),
]
#expect(ChatToolDiff.computeLineDiff(
old: oldLines.joined(separator: "\n"),
new: newLines.joined(separator: "\n")) == expected)
}
@Test func `details diff wins over argument fallback`() throws {
let resolved = try #require(ChatToolDiff.resolveDiff(
name: "edit",
arguments: AnyCodable(["oldText": "arg old", "newText": "arg new"]),
details: AnyCodable(["diff": "-12 detail old\n+12 detail new"])))
#expect(resolved.lines == [
ChatToolDiffLine(kind: .del, lineNo: 12, text: "detail old"),
ChatToolDiffLine(kind: .add, lineNo: 12, text: "detail new"),
])
#expect(resolved.stat == ChatToolDiffStat(added: 1, removed: 1))
}
@Test func `bare details skip keeps an exact stat`() throws {
let resolved = try #require(ChatToolDiff.resolveDiff(
name: "edit",
arguments: nil,
details: AnyCodable(["diff": "+1 added\n...\n-2 removed"])))
#expect(resolved.stat == ChatToolDiffStat(added: 1, removed: 1))
}
@Test func `reads multi edit pairs`() throws {
let resolved = try #require(ChatToolDiff.resolveDiff(
name: "multiedit",
arguments: AnyCodable(["edits": [
["oldText": "one", "newText": "uno"],
["old_string": "two", "new_string": "dos"],
]]),
details: nil))
#expect(resolved.lines == [
ChatToolDiffLine(kind: .del, text: "one"),
ChatToolDiffLine(kind: .add, text: "uno"),
ChatToolDiffLine(kind: .skip, text: ""),
ChatToolDiffLine(kind: .del, text: "two"),
ChatToolDiffLine(kind: .add, text: "dos"),
])
#expect(resolved.stat == ChatToolDiffStat(added: 2, removed: 2))
}
@Test func `renders write content as additions`() throws {
let resolved = try #require(ChatToolDiff.resolveDiff(
name: "write_file",
arguments: AnyCodable(["content": "one\ntwo\n"]),
details: nil))
#expect(resolved.lines == [
ChatToolDiffLine(kind: .add, lineNo: 1, text: "one"),
ChatToolDiffLine(kind: .add, lineNo: 2, text: "two"),
])
#expect(resolved.stat == ChatToolDiffStat(added: 2, removed: 0))
}
@Test func `write created flags control fallback stats`() throws {
let arguments = AnyCodable(["content": "one\ntwo\n"])
let created = try #require(ChatToolDiff.resolveDiff(
name: "write",
arguments: arguments,
details: AnyCodable(["created": true])))
let overwritten = try #require(ChatToolDiff.resolveDiff(
name: "write",
arguments: arguments,
details: AnyCodable(["created": false])))
let unknown = try #require(ChatToolDiff.resolveDiff(
name: "write",
arguments: arguments,
details: AnyCodable(["changed": true])))
#expect(created.stat == ChatToolDiffStat(added: 2, removed: 0))
#expect(overwritten.lines == created.lines)
#expect(overwritten.stat == nil)
#expect(unknown.lines == created.lines)
#expect(unknown.stat == nil)
#expect(ChatToolDiff.resolveDiff(
name: "write",
arguments: arguments,
details: AnyCodable(["changed": false])) == nil)
}
@Test func `renders editor create and insert commands`() throws {
let create = try #require(ChatToolDiff.resolveDiff(
name: "str_replace_editor",
arguments: AnyCodable(["command": "create", "file_text": "one\ntwo"]),
details: nil))
#expect(create.lines == [
ChatToolDiffLine(kind: .add, lineNo: 1, text: "one"),
ChatToolDiffLine(kind: .add, lineNo: 2, text: "two"),
])
#expect(create.stat == ChatToolDiffStat(added: 2, removed: 0))
let createFallback = try #require(ChatToolDiff.resolveDiff(
name: "str_replace_editor",
arguments: AnyCodable(["command": "create", "content": "fallback"]),
details: nil))
#expect(createFallback.lines == [ChatToolDiffLine(kind: .add, lineNo: 1, text: "fallback")])
#expect(createFallback.stat == ChatToolDiffStat(added: 1, removed: 0))
let overwrittenCreate = try #require(ChatToolDiff.resolveDiff(
name: "str_replace_editor",
arguments: AnyCodable(["command": "create", "file_text": "replacement"]),
details: AnyCodable(["created": false])))
#expect(overwrittenCreate.stat == nil)
#expect(ChatToolDiff.resolveDiff(
name: "str_replace_editor",
arguments: AnyCodable(["command": "create", "file_text": "unchanged"]),
details: AnyCodable(["changed": false])) == nil)
let insert = try #require(ChatToolDiff.resolveDiff(
name: "str_replace_based_edit_tool",
arguments: AnyCodable(["command": "insert", "insert_text": "three"]),
details: nil))
#expect(insert.lines == [ChatToolDiffLine(kind: .add, text: "three")])
#expect(insert.stat == nil)
}
@Test func `keeps a full write stat when the preview is clipped`() throws {
let content = (0...400).map { "line \($0)" }.joined(separator: "\n")
let resolved = try #require(ChatToolDiff.resolveDiff(
name: "write",
arguments: AnyCodable(["content": content]),
details: nil))
#expect(resolved.lines.count == 81)
#expect(resolved.lines.last == ChatToolDiffLine(kind: .skip, text: ""))
#expect(resolved.stat == ChatToolDiffStat(added: 401, removed: 0))
}
@Test func `unknown tools do not resolve local diffs`() {
#expect(ChatToolDiff.resolveDiff(
name: "custom_tool",
arguments: AnyCodable(["oldText": "old", "newText": "new"]),
details: nil) == nil)
}
@Test func `unknown tools never interpret details as a diff`() {
#expect(ChatToolDiff.resolveDiff(
name: "custom_tool",
arguments: nil,
details: AnyCodable(["diff": AnyCodable("+1 added\n-1 removed")])) == nil)
}
@Test func `move only patches render the file header`() throws {
let patch = "*** Begin Patch\n*** Update File: a/old.txt\n*** Move to: a/new.txt\n*** End Patch"
let resolved = try #require(ChatToolDiff.resolveDiff(
name: "apply_patch",
arguments: AnyCodable(["input": AnyCodable(patch)]),
details: nil))
#expect(resolved.lines == [ChatToolDiffLine(kind: .file, text: "Move a/old.txt → a/new.txt")])
#expect(resolved.stat == nil)
}
@Test func `patch tools resolve persisted details`() throws {
let resolved = try #require(ChatToolDiff.resolveDiff(
name: "apply_patch",
arguments: nil,
details: AnyCodable(["diff": AnyCodable("+1 added\n-1 removed")])))
#expect(resolved.stat == ChatToolDiffStat(added: 1, removed: 1))
}
@Test func `parses numbered update patch envelopes from every argument spelling`() throws {
let patch = [
"*** Begin Patch",
"*** Update File: src/a.swift",
"@@ -4,2 +4,2 @@",
" context",
"-old",
"+new",
"*** End Patch",
].joined(separator: "\n")
for key in ["patch", "input", "diff"] {
let resolved = try #require(ChatToolDiff.resolveDiff(
name: "apply_patch",
arguments: AnyCodable([key: AnyCodable(patch)]),
details: nil))
#expect(resolved.lines == [
ChatToolDiffLine(kind: .ctx, lineNo: 4, text: "context"),
ChatToolDiffLine(kind: .del, lineNo: 5, text: "old"),
ChatToolDiffLine(kind: .add, lineNo: 5, text: "new"),
])
#expect(resolved.stat == ChatToolDiffStat(added: 1, removed: 1))
}
}
@Test func `patch envelope precedence matches transcript caching`() throws {
let input = "*** Begin Patch\n*** Add File: input.txt\n+input\n*** End Patch"
let patch = "*** Begin Patch\n*** Add File: patch.txt\n+patch\n*** End Patch"
let resolved = try #require(ChatToolDiff.resolveDiff(
name: "apply_patch",
arguments: AnyCodable([
"input": AnyCodable(input),
"patch": AnyCodable(patch),
]),
details: nil))
#expect(resolved.lines.first == ChatToolDiffLine(kind: .add, lineNo: 1, text: "input"))
}
@Test func `separates add delete and move patch files`() throws {
let patch = [
"*** Begin Patch",
"*** Update File: src/old.swift",
"*** Move to: src/new.swift",
"@@ -1 +1 @@",
"-old",
"+new",
"*** Add File: src/added.swift",
"+added",
"*** Delete File: src/deleted.swift",
"-deleted",
"*** End Patch",
].joined(separator: "\n")
let resolved = try #require(ChatToolDiff.resolveDiff(
name: "applypatch",
arguments: AnyCodable(["input": AnyCodable(patch)]),
details: nil))
#expect(resolved.lines == [
ChatToolDiffLine(kind: .file, text: "Move src/old.swift → src/new.swift"),
ChatToolDiffLine(kind: .del, lineNo: 1, text: "old"),
ChatToolDiffLine(kind: .add, lineNo: 1, text: "new"),
ChatToolDiffLine(kind: .skip, text: ""),
ChatToolDiffLine(kind: .file, text: "Add src/added.swift"),
ChatToolDiffLine(kind: .add, lineNo: 1, text: "added"),
ChatToolDiffLine(kind: .skip, text: ""),
ChatToolDiffLine(kind: .file, text: "Delete src/deleted.swift"),
ChatToolDiffLine(kind: .del, lineNo: 1, text: "deleted"),
])
#expect(resolved.stat == ChatToolDiffStat(added: 2, removed: 2))
}
@Test func `rejects malformed patch envelopes`() {
for patch in [
"*** Begin Patch\nnot a file\n*** End Patch",
"*** Update File: \n+orphaned",
"*** Add File: empty.swift\n*** End Patch",
] {
#expect(ChatToolDiff.resolveDiff(
name: "patch",
arguments: AnyCodable(["patch": AnyCodable(patch)]),
details: nil) == nil)
}
}
@Test func `renders header only deletes without an exact stat`() throws {
let deleted = try #require(ChatToolDiff.resolveDiff(
name: "apply_patch",
arguments: AnyCodable(["patch": AnyCodable("*** Delete File: obsolete.swift")]),
details: nil))
#expect(deleted.lines == [ChatToolDiffLine(kind: .file, text: "Delete obsolete.swift")])
#expect(deleted.stat == nil)
let multi = try #require(ChatToolDiff.resolveDiff(
name: "apply_patch",
arguments: AnyCodable(["patch": AnyCodable(
"*** Add File: added.swift\n+new\n*** Delete File: obsolete.swift")]),
details: nil))
#expect(multi.lines.last == ChatToolDiffLine(kind: .file, text: "Delete obsolete.swift"))
#expect(multi.stat == nil)
}
@Test func `caps patch rows and omits a partial stat`() throws {
let patch = (["*** Begin Patch", "*** Update File: big.swift"] +
(0..<450).map { "+line \($0)" } + ["*** End Patch"])
.joined(separator: "\n")
let resolved = try #require(ChatToolDiff.resolveDiff(
name: "apply_patch",
arguments: AnyCodable(["patch": AnyCodable(patch)]),
details: nil))
#expect(resolved.lines.count == 401)
#expect(resolved.lines.last == ChatToolDiffLine(kind: .skip, text: ""))
#expect(resolved.stat == nil)
}
@Test func `failed edits suppress argument proposals but keep applied details`() throws {
#expect(ChatToolDiff.resolveDiff(
name: "edit",
arguments: AnyCodable(["oldText": "old", "newText": "new"]),
details: nil,
isError: true) == nil)
let details = try #require(ChatToolDiff.resolveDiff(
name: "edit",
arguments: AnyCodable(["oldText": "arg old", "newText": "arg new"]),
details: AnyCodable(["diff": AnyCodable("-1 applied old\n+1 applied new")]),
isError: true))
#expect(details.lines == [
ChatToolDiffLine(kind: .del, lineNo: 1, text: "applied old"),
ChatToolDiffLine(kind: .add, lineNo: 1, text: "applied new"),
])
#expect(details.stat == ChatToolDiffStat(added: 1, removed: 1))
}
@Test func `truncated details omit the stat`() throws {
let resolved = try #require(ChatToolDiff.resolveDiff(
name: "edit",
arguments: nil,
details: AnyCodable(["diff": "+1 kept\n...(truncated)..."])))
#expect(resolved.stat == nil)
}
@Test func `capped details omit the stat`() throws {
let diff = (1...401).map { "+\($0) line \($0)" }.joined(separator: "\n")
let resolved = try #require(ChatToolDiff.resolveDiff(
name: "edit",
arguments: nil,
details: AnyCodable(["diff": diff])))
#expect(resolved.lines.count == 401)
#expect(resolved.lines.last == ChatToolDiffLine(kind: .skip, text: ""))
#expect(resolved.stat == nil)
}
@Test func `message details survive coding roundtrip`() throws {
let data = Data(#"{"role":"toolResult","content":"done","details":{"diff":"+1 added"}}"#.utf8)
let decoded = try JSONDecoder().decode(OpenClawChatMessage.self, from: data)
let roundTripped = try JSONDecoder().decode(
OpenClawChatMessage.self,
from: JSONEncoder().encode(decoded))
#expect(decoded.details == AnyCodable(["diff": AnyCodable("+1 added")]))
#expect(roundTripped.details == decoded.details)
}
@Test func `message and content decode snake case errors and encode canonically`() throws {
let data = Data(#"{"role":"toolResult","is_error":true,"content":[{"type":"tool_result","is_error":true}]}"#
.utf8)
let decoded = try JSONDecoder().decode(OpenClawChatMessage.self, from: data)
let encoded = try JSONEncoder().encode(decoded)
let object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
let content = try #require((object["content"] as? [[String: Any]])?.first)
#expect(decoded.isError == true)
#expect(decoded.content.first?.isError == true)
#expect(object["isError"] as? Bool == true)
#expect(object["is_error"] == nil)
#expect(content["isError"] as? Bool == true)
#expect(content["is_error"] == nil)
}
}