mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 10:11:14 +00:00
* 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
440 lines
18 KiB
Swift
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)
|
|
}
|
|
}
|