feat(chat-ui): tool-diff follow-ups — cached diffs, apply_patch, isError, long lines (#111326)

* feat(chat-ui): tool-diff follow-ups from PR review

Persist bounded applied-diff details in the transcript cache so cold
opens keep inline diffs; parse apply_patch envelopes into per-file diff
sections with stats; thread tool-result isError so failed calls suppress
proposal diffs and read as failures; replace per-line truncation with
horizontal scrolling for long diff lines.

* chore(i18n): sync native inventory for tool-diff follow-ups

* fix(chat-ui): render move-only patches and drop zero diffstats
This commit is contained in:
Peter Steinberger
2026-07-19 02:40:37 -07:00
committed by GitHub
parent c7d2d111d8
commit e0ecdc47be
12 changed files with 633 additions and 57 deletions
+23 -23
View File
@@ -37475,7 +37475,7 @@
},
{
"kind": "conditional-branch",
"line": 554,
"line": 555,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Voice note",
"surface": "apple",
@@ -37483,7 +37483,7 @@
},
{
"kind": "ui-call",
"line": 599,
"line": 600,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Writing",
"surface": "apple",
@@ -37491,7 +37491,7 @@
},
{
"kind": "ui-call",
"line": 625,
"line": 626,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Preparing audio…",
"surface": "apple",
@@ -37499,7 +37499,7 @@
},
{
"kind": "ui-call",
"line": 628,
"line": 629,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Speaking…",
"surface": "apple",
@@ -37507,7 +37507,7 @@
},
{
"kind": "conditional-branch",
"line": 636,
"line": 637,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Preparing audio, tap to cancel",
"surface": "apple",
@@ -37515,7 +37515,7 @@
},
{
"kind": "conditional-branch",
"line": 637,
"line": 638,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Speaking, tap to stop",
"surface": "apple",
@@ -38611,7 +38611,7 @@
},
{
"kind": "ui-localized-call",
"line": 80,
"line": 83,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift",
"source": "Running",
"surface": "apple",
@@ -38619,7 +38619,7 @@
},
{
"kind": "conditional-branch",
"line": 129,
"line": 133,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift",
"source": "Collapse tool result",
"surface": "apple",
@@ -38627,7 +38627,7 @@
},
{
"kind": "conditional-branch",
"line": 129,
"line": 133,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift",
"source": "Expand tool result",
"surface": "apple",
@@ -38635,7 +38635,7 @@
},
{
"kind": "ui-localized-call",
"line": 165,
"line": 169,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift",
"source": "Show less",
"surface": "apple",
@@ -38643,7 +38643,7 @@
},
{
"kind": "ui-localized-call",
"line": 167,
"line": 171,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift",
"source": "Show all %lld lines",
"surface": "apple",
@@ -38651,7 +38651,7 @@
},
{
"kind": "ui-call",
"line": 256,
"line": 272,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift",
"source": "Collapsed",
"surface": "apple",
@@ -38659,7 +38659,7 @@
},
{
"kind": "conditional-branch",
"line": 1347,
"line": 1377,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift",
"source": "INSERT INTO cached_transcripts(gateway_id, session_key, agent_id, payload, updated_at)\nSELECT gateway_id, session_key, agent_id, payload, updated_at\nFROM cached_transcripts_pre_v3",
"surface": "apple",
@@ -38667,7 +38667,7 @@
},
{
"kind": "conditional-branch",
"line": 1352,
"line": 1382,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift",
"source": "INSERT INTO cached_transcripts(gateway_id, session_key, agent_id, payload, updated_at)\nSELECT gateway_id, session_key, '', payload, updated_at\nFROM cached_transcripts_pre_v3\nWHERE lower(trim(session_key)) GLOB 'agent:*:*'",
"surface": "apple",
@@ -38747,7 +38747,7 @@
},
{
"kind": "ui-call",
"line": 1083,
"line": 1085,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Copy Message",
"surface": "apple",
@@ -38755,7 +38755,7 @@
},
{
"kind": "ui-call",
"line": 1106,
"line": 1108,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Open Full Message",
"surface": "apple",
@@ -38763,7 +38763,7 @@
},
{
"kind": "ui-call",
"line": 1125,
"line": 1127,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Rewind to Here",
"surface": "apple",
@@ -38771,7 +38771,7 @@
},
{
"kind": "ui-call",
"line": 1145,
"line": 1147,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Fork from Here",
"surface": "apple",
@@ -38779,7 +38779,7 @@
},
{
"kind": "ui-localized-call",
"line": 1173,
"line": 1175,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Reply",
"surface": "apple",
@@ -38787,7 +38787,7 @@
},
{
"kind": "ui-localized-call",
"line": 1183,
"line": 1185,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "You",
"surface": "apple",
@@ -38795,7 +38795,7 @@
},
{
"kind": "ui-localized-call",
"line": 1185,
"line": 1187,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Assistant",
"surface": "apple",
@@ -38803,7 +38803,7 @@
},
{
"kind": "ui-call",
"line": 1251,
"line": 1253,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Loading chat",
"surface": "apple",
@@ -38811,7 +38811,7 @@
},
{
"kind": "ui-modifier",
"line": 1331,
"line": 1333,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Dismiss",
"surface": "apple",
@@ -395,6 +395,7 @@ private struct ChatMessageBody: View {
arguments: nil,
details: self.message.details,
resultText: self.primaryText,
isError: self.message.isError ?? false,
isPending: false)]
}
guard self.message.role.lowercased() == "assistant" else { return [] }
@@ -813,6 +814,7 @@ struct ChatPendingToolsBubble: View {
arguments: call.args,
details: nil,
resultText: nil,
isError: false,
isPending: true)
}
}
@@ -125,6 +125,7 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable {
public let name: String?
public let arguments: AnyCodable?
public let details: AnyCodable?
public let isError: Bool?
public init(
type: String?,
@@ -139,7 +140,8 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable {
id: String? = nil,
name: String? = nil,
arguments: AnyCodable? = nil,
details: AnyCodable? = nil)
details: AnyCodable? = nil,
isError: Bool? = nil)
{
self.type = type
self.text = text
@@ -154,6 +156,7 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable {
self.name = name
self.arguments = arguments
self.details = details
self.isError = isError
}
enum CodingKeys: String, CodingKey {
@@ -170,6 +173,8 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable {
case name
case arguments
case details
case isError
case is_error
}
public init(from decoder: Decoder) throws {
@@ -185,6 +190,8 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable {
self.name = try container.decodeIfPresent(String.self, forKey: .name)
self.arguments = try container.decodeIfPresent(AnyCodable.self, forKey: .arguments)
self.details = try container.decodeIfPresent(AnyCodable.self, forKey: .details)
self.isError = try container.decodeIfPresent(Bool.self, forKey: .isError) ??
container.decodeIfPresent(Bool.self, forKey: .is_error)
self.preview = try container.decodeIfPresent(OpenClawChatCanvasPreview.self, forKey: .preview)
if let any = try container.decodeIfPresent(AnyCodable.self, forKey: .content) {
@@ -195,6 +202,24 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable {
self.content = nil
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(self.type, forKey: .type)
try container.encodeIfPresent(self.text, forKey: .text)
try container.encodeIfPresent(self.thinking, forKey: .thinking)
try container.encodeIfPresent(self.thinkingSignature, forKey: .thinkingSignature)
try container.encodeIfPresent(self.mimeType, forKey: .mimeType)
try container.encodeIfPresent(self.fileName, forKey: .fileName)
try container.encodeIfPresent(self.durationSeconds, forKey: .durationSeconds)
try container.encodeIfPresent(self.content, forKey: .content)
try container.encodeIfPresent(self.preview, forKey: .preview)
try container.encodeIfPresent(self.id, forKey: .id)
try container.encodeIfPresent(self.name, forKey: .name)
try container.encodeIfPresent(self.arguments, forKey: .arguments)
try container.encodeIfPresent(self.details, forKey: .details)
try container.encodeIfPresent(self.isError, forKey: .isError)
}
}
public struct OpenClawChatCanvasPreview: Codable, Hashable, Sendable {
@@ -243,6 +268,7 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
public let stopReason: String?
public let errorMessage: String?
public let details: AnyCodable?
public let isError: Bool?
enum CodingKeys: String, CodingKey {
case role
@@ -258,6 +284,8 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
case stopReason
case errorMessage
case details
case isError
case is_error
case mediaPath = "MediaPath"
case mediaPaths = "MediaPaths"
case mediaType = "MediaType"
@@ -277,7 +305,8 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
usage: OpenClawChatUsage? = nil,
stopReason: String? = nil,
errorMessage: String? = nil,
details: AnyCodable? = nil)
details: AnyCodable? = nil,
isError: Bool? = nil)
{
self.id = id
self.transcriptMessageID = transcriptMessageID
@@ -292,6 +321,7 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
self.stopReason = stopReason
self.errorMessage = errorMessage
self.details = details
self.isError = isError
}
public init(from decoder: Decoder) throws {
@@ -311,6 +341,8 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
let decodedStopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
let decodedErrorMessage = try container.decodeIfPresent(String.self, forKey: .errorMessage)
let decodedDetails = try container.decodeIfPresent(AnyCodable.self, forKey: .details)
let decodedIsError = try container.decodeIfPresent(Bool.self, forKey: .isError) ??
container.decodeIfPresent(Bool.self, forKey: .is_error)
self.role = decodedRole
self.transcriptMessageID = decodedOpenClaw?.id
@@ -322,6 +354,7 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
self.stopReason = decodedStopReason
self.errorMessage = decodedErrorMessage
self.details = decodedDetails
self.isError = decodedIsError
let decodedContent: [OpenClawChatMessageContent] = if let decoded = try? container.decode(
[OpenClawChatMessageContent].self,
@@ -432,6 +465,7 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
try container.encodeIfPresent(self.stopReason, forKey: .stopReason)
try container.encodeIfPresent(self.errorMessage, forKey: .errorMessage)
try container.encodeIfPresent(self.details, forKey: .details)
try container.encodeIfPresent(self.isError, forKey: .isError)
try container.encode(self.content, forKey: .content)
}
}
@@ -11,6 +11,7 @@ struct ChatToolActivityItem: Identifiable, Equatable {
let arguments: AnyCodable?
let details: AnyCodable?
let resultText: String?
let isError: Bool
let isPending: Bool
}
@@ -32,6 +33,7 @@ enum ChatToolActivity {
arguments: call.arguments,
details: result?.details,
resultText: result?.text,
isError: result?.isError ?? false,
isPending: false)
}
@@ -42,6 +44,7 @@ enum ChatToolActivity {
arguments: nil,
details: result.details,
resultText: result.text,
isError: result.isError ?? false,
isPending: false)
})
return items
@@ -108,7 +111,8 @@ struct ChatToolActivityRow: View {
self.resolvedDiff = ChatToolDiff.resolveDiff(
name: item.name,
arguments: item.arguments,
details: item.details)
details: item.details,
isError: item.isError)
}
var body: some View {
@@ -202,11 +206,12 @@ struct ChatToolActivityRow: View {
Image(systemName: Self.symbol(forToolName: self.item.name))
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary)
.foregroundStyle(self.item.isError ? OpenClawChatTheme.danger : Color.secondary)
Text(self.display.title)
.font(OpenClawChatTypography.footnoteSemiBold)
.foregroundStyle(OpenClawChatTheme.assistantText)
.foregroundStyle(
self.item.isError ? OpenClawChatTheme.danger : OpenClawChatTheme.assistantText)
.lineLimit(1)
if let detailLine = self.detailLine {
@@ -234,18 +239,29 @@ struct ChatToolActivityRow: View {
}
private var diffRows: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(self.expandedDiffLines.indices, id: \.self) { index in
self.diffRow(self.expandedDiffLines[index])
// The orthogonal nested scroll keeps long diff lines reachable without
// competing with the transcript's vertical gesture.
ScrollView(.horizontal, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
ForEach(self.expandedDiffLines.indices, id: \.self) { index in
self.diffRow(self.expandedDiffLines[index])
}
}
.textSelection(.enabled)
}
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
@ViewBuilder
private func diffRow(_ line: ChatToolDiffLine) -> some View {
if line.kind == .skip {
if line.kind == .file {
Text(verbatim: String(line.text.unicodeScalars.prefix(2000)))
.font(OpenClawChatTypography.mono(size: 11, relativeTo: .caption))
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.fixedSize(horizontal: true, vertical: false)
.padding(.top, 6)
} else if line.kind == .skip {
Text("")
.font(OpenClawChatTypography.mono(size: 12, relativeTo: .footnote))
.foregroundStyle(.secondary.opacity(0.6))
@@ -267,9 +283,7 @@ struct ChatToolActivityRow: View {
Text(verbatim: String(line.text.unicodeScalars.prefix(2000)))
.font(OpenClawChatTypography.mono(size: 12, relativeTo: .footnote))
.foregroundStyle(self.diffTextColor(line.kind))
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: true, vertical: false)
}
.background(self.diffBackground(line.kind))
// Color alone must not carry add/del semantics for assistive tech.
@@ -289,7 +303,7 @@ struct ChatToolActivityRow: View {
"+ "
case .del:
"\u{2212} "
case .ctx, .skip:
case .ctx, .file, .skip:
""
}
}
@@ -298,7 +312,7 @@ struct ChatToolActivityRow: View {
switch kind {
case .add:
OpenClawChatTheme.assistantText
case .del, .ctx, .skip:
case .del, .ctx, .file, .skip:
.secondary
}
}
@@ -309,7 +323,7 @@ struct ChatToolActivityRow: View {
OpenClawChatTheme.success.opacity(0.14)
case .del:
OpenClawChatTheme.danger.opacity(0.12)
case .ctx, .skip:
case .ctx, .file, .skip:
.clear
}
}
@@ -5,6 +5,7 @@ enum ChatToolDiffLineKind: Equatable, Sendable {
case add
case del
case ctx
case file
case skip
}
@@ -36,6 +37,26 @@ enum ChatToolDiff {
let truncated: Bool
}
private enum PatchOperation {
case add
case delete
case update
}
private struct PatchSection {
var operation: PatchOperation
let sourcePath: String
var path: String
var lines: [ChatToolDiffLine] = []
var added = 0
var removed = 0
}
private struct PatchHunk {
var oldLine: Int?
var newLine: Int?
}
private static let maxInputLines = 600
private static let maxRenderLines = 400
private static let maxLocalPairs = 8
@@ -144,11 +165,18 @@ enum ChatToolDiff {
static func resolveDiff(
name: String?,
arguments: AnyCodable?,
details: AnyCodable?) -> (lines: [ChatToolDiffLine], stat: ChatToolDiffStat?)?
details: AnyCodable?,
isError: Bool = false) -> (lines: [ChatToolDiffLine], stat: ChatToolDiffStat?)?
{
let normalizedName = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
let argumentsRecord = arguments?.dictionaryValue
if self.textEditorToolNames.contains(normalizedName) {
if let detailsDiff = self.resolveDetailsDiff(details) {
// Applied diff details stay authoritative even when the result reports an error.
return detailsDiff
}
// Failed args describe a proposal, not a mutation known to have been applied.
guard !isError else { return nil }
switch self.string(in: argumentsRecord, keys: ["command"])?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
@@ -167,16 +195,25 @@ enum ChatToolDiff {
// Plugin tools own their details schema; only edit-family tools may
// interpret details.diff as a filesystem diff (mirrors the web guard).
if self.editToolNames.contains(normalizedName) {
return self.resolveEditDiff(argumentsRecord, details: details)
if let detailsDiff = self.resolveDetailsDiff(details) {
return detailsDiff
}
guard !isError else { return nil }
return self.resolveEditDiff(argumentsRecord, details: nil)
}
if self.writeToolNames.contains(normalizedName) {
if let detailsDiff = self.resolveDetailsDiff(details) {
return detailsDiff
}
guard !isError else { return nil }
return self.resolveWriteDiff(argumentsRecord, keys: ["content", "text", "file_text"])
}
if self.patchToolNames.contains(normalizedName) {
return self.resolveDetailsDiff(details)
if let detailsDiff = self.resolveDetailsDiff(details) {
return detailsDiff
}
guard !isError else { return nil }
return self.resolvePatchDiff(argumentsRecord)
}
return nil
}
@@ -203,6 +240,232 @@ enum ChatToolDiff {
return ChatToolDiffLine(kind: kind, lineNo: lineNo, text: String(remainder))
}
private static func resolvePatchDiff(
_ arguments: [String: AnyCodable]?) -> (lines: [ChatToolDiffLine], stat: ChatToolDiffStat?)?
{
guard let patch = self.string(in: arguments, keys: ["patch", "input", "diff"]),
!patch.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else { return nil }
let inputClipped = patch.utf16.count > self.maxLocalInputCharacters
let text = inputClipped
? String(decoding: patch.utf16.prefix(self.maxLocalInputCharacters), as: UTF16.self)
: patch
var sections: [PatchSection] = []
var current: PatchSection?
var hunk = PatchHunk()
var storedRows = 0
var clipped = inputClipped
for raw in self.splitLines(text) {
let structural = current?.operation == .update
? raw.replacingOccurrences(of: #"[ \t]+$"#, with: "", options: .regularExpression)
: raw.trimmingCharacters(in: .whitespaces)
if let header = self.patchFileHeader(structural) {
if let current { sections.append(current) }
current = PatchSection(
operation: header.operation,
sourcePath: header.path,
path: header.path)
hunk = PatchHunk()
continue
}
if current?.operation == .update,
structural.hasPrefix("*** Move to: "),
case let path = String(structural.dropFirst("*** Move to: ".count))
.trimmingCharacters(in: .whitespacesAndNewlines),
!path.isEmpty
{
current?.path = path
continue
}
if structural == "*** Begin Patch" || structural == "*** End Patch" ||
structural == "*** End of File" || structural.hasPrefix("*** Environment ID:")
{
continue
}
guard var section = current else { continue }
if section.operation == .update, raw.hasPrefix("@@") {
if !section.lines.isEmpty, section.lines.last?.kind != .skip {
self.pushPatchLine(
ChatToolDiffLine(kind: .skip, text: ""),
section: &section,
storedRows: &storedRows,
clipped: &clipped)
}
hunk = self.parsePatchHunk(raw)
} else if section.operation == .add, raw.hasPrefix("+") {
self.pushPatchLine(
ChatToolDiffLine(kind: .add, lineNo: section.added + 1, text: String(raw.dropFirst())),
section: &section,
storedRows: &storedRows,
clipped: &clipped)
} else if section.operation == .delete, raw.hasPrefix("-") {
self.pushPatchLine(
ChatToolDiffLine(kind: .del, lineNo: section.removed + 1, text: String(raw.dropFirst())),
section: &section,
storedRows: &storedRows,
clipped: &clipped)
} else if section.operation == .update,
raw.isEmpty || raw.hasPrefix("+") || raw.hasPrefix("-") || raw.hasPrefix(" ")
{
self.pushPatchHunkLine(
raw,
hunk: &hunk,
section: &section,
storedRows: &storedRows,
clipped: &clipped)
}
current = section
}
if let current { sections.append(current) }
return self.finishPatch(sections, clipped: clipped)
}
private static func patchFileHeader(
_ raw: String) -> (operation: PatchOperation, path: String)?
{
let headers: [(String, PatchOperation)] = [
("*** Update File: ", .update),
("*** Add File: ", .add),
("*** Delete File: ", .delete),
]
for (prefix, operation) in headers where raw.hasPrefix(prefix) {
let path = raw.dropFirst(prefix.count).trimmingCharacters(in: .whitespacesAndNewlines)
guard !path.isEmpty else { return nil }
return (operation, path)
}
return nil
}
private static func pushPatchLine(
_ line: ChatToolDiffLine,
section: inout PatchSection,
storedRows: inout Int,
clipped: inout Bool)
{
switch line.kind {
case .add:
section.added += 1
case .del:
section.removed += 1
case .ctx, .file, .skip:
break
}
if storedRows < self.maxRenderLines {
section.lines.append(line)
storedRows += 1
} else {
clipped = true
}
}
private static func parsePatchHunk(_ raw: String) -> PatchHunk {
guard raw.hasPrefix("@@ -") else { return PatchHunk() }
let body = raw.dropFirst(4)
guard let plus = body.range(of: " +"),
let end = body[plus.upperBound...].range(of: " @@")
else { return PatchHunk() }
let old = body[..<plus.lowerBound]
let new = body[plus.upperBound..<end.lowerBound]
func line(_ rangeText: Substring) -> Int? {
Int(rangeText.split(separator: ",", maxSplits: 1).first ?? "")
}
guard let oldLine = line(old), let newLine = line(new) else { return PatchHunk() }
return PatchHunk(oldLine: oldLine, newLine: newLine)
}
private static func pushPatchHunkLine(
_ raw: String,
hunk: inout PatchHunk,
section: inout PatchSection,
storedRows: inout Int,
clipped: inout Bool)
{
let kind: ChatToolDiffLineKind
let lineNo: Int?
if raw.hasPrefix("+") {
kind = .add
lineNo = hunk.newLine
if let newLine = hunk.newLine {
hunk.newLine = newLine + 1
}
} else if raw.hasPrefix("-") {
kind = .del
lineNo = hunk.oldLine
if let oldLine = hunk.oldLine {
hunk.oldLine = oldLine + 1
}
} else {
kind = .ctx
lineNo = hunk.newLine
if let oldLine = hunk.oldLine, let newLine = hunk.newLine {
hunk.oldLine = oldLine + 1
hunk.newLine = newLine + 1
}
}
self.pushPatchLine(
ChatToolDiffLine(kind: kind, lineNo: lineNo, text: raw.isEmpty ? "" : String(raw.dropFirst())),
section: &section,
storedRows: &storedRows,
clipped: &clipped)
}
private static func finishPatch(
_ sections: [PatchSection],
clipped initialClipped: Bool) -> (lines: [ChatToolDiffLine], stat: ChatToolDiffStat?)?
{
guard !sections.isEmpty else { return nil }
var lines: [ChatToolDiffLine] = []
var clipped = initialClipped
let hasHeaderOnlyDelete = sections.contains { section in
section.operation == .delete && !section.lines.contains(where: { $0.kind == .del })
}
func append(_ line: ChatToolDiffLine) {
if lines.count < self.maxRenderLines {
lines.append(line)
} else {
clipped = true
}
}
for section in sections {
// Header rows also carry move-only and header-only sections that
// would otherwise render nothing.
let isHeaderOnly = section.lines.isEmpty &&
(section.operation == .delete || section.path != section.sourcePath)
if sections.count > 1 || isHeaderOnly {
if !lines.isEmpty, lines.last?.kind != .skip {
append(ChatToolDiffLine(kind: .skip, text: ""))
}
append(ChatToolDiffLine(kind: .file, text: self.patchSectionLabel(section)))
}
section.lines.forEach(append)
}
if clipped, lines.last?.kind != .skip {
lines.append(ChatToolDiffLine(kind: .skip, text: ""))
}
guard lines.contains(where: { $0.kind == .add || $0.kind == .del || $0.kind == .file }) else { return nil }
let stat = self.stat(for: lines)
// Zero/zero (move-only) and unknowable header-only-delete stats read as noise or lies.
let statMeaningful = !clipped && !hasHeaderOnlyDelete && (stat.added > 0 || stat.removed > 0)
return (lines, statMeaningful ? stat : nil)
}
private static func patchSectionLabel(_ section: PatchSection) -> String {
if section.operation == .update, section.path != section.sourcePath {
return "Move \(section.sourcePath)\(section.path)"
}
let verb = switch section.operation {
case .add: "Add"
case .delete: "Delete"
case .update: "Update"
}
return "\(verb) \(section.path)"
}
private static func splitLines(_ text: String) -> [String] {
let normalized = text
.replacingOccurrences(of: "\r\n", with: "\n")
@@ -423,7 +686,7 @@ enum ChatToolDiff {
ChatToolDiffStat(added: stat.added + 1, removed: stat.removed)
case .del:
ChatToolDiffStat(added: stat.added, removed: stat.removed + 1)
case .ctx, .skip:
case .ctx, .file, .skip:
stat
}
}
@@ -1,4 +1,5 @@
import Foundation
import OpenClawKit
import OSLog
import SQLite3
#if os(iOS)
@@ -879,8 +880,9 @@ extension OpenClawChatSQLiteTranscriptCache {
extension OpenClawChatSQLiteTranscriptCache {
// MARK: - Cached shapes
/// Text rows only in v1: strip attachment/binary payloads and tool
/// arguments so the cache never persists base64 blobs or large payloads.
/// Cache v1 strips attachments and tool arguments; args-derived fallback
/// diffs intentionally do not survive cold paint. Only bounded applied
/// diff details remain so the JSON payload cannot grow without limit.
static func cacheableMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
messages.suffix(self.maxCachedMessagesPerSession).map { message in
OpenClawChatMessage(
@@ -898,7 +900,9 @@ extension OpenClawChatSQLiteTranscriptCache {
content: nil,
id: item.id,
name: item.name,
arguments: nil)
arguments: nil,
details: self.cacheableDetails(item.details),
isError: item.isError)
},
timestamp: message.timestamp,
idempotencyKey: message.idempotencyKey,
@@ -906,10 +910,36 @@ extension OpenClawChatSQLiteTranscriptCache {
toolName: message.toolName,
usage: message.usage,
stopReason: message.stopReason,
errorMessage: message.errorMessage)
errorMessage: message.errorMessage,
details: self.cacheableDetails(message.details),
isError: message.isError)
}
}
private static func cacheableDetails(_ details: AnyCodable?) -> AnyCodable? {
guard let diff = details?.dictionaryValue?["diff"]?.stringValue else { return nil }
let limit = 64000
let truncationMarker = "\n...(truncated)..."
let capped = if diff.utf16.count > limit {
self.utf16Prefix(diff, limit: limit - truncationMarker.utf16.count) + truncationMarker
} else {
diff
}
guard !capped.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
return AnyCodable(["diff": AnyCodable(capped)])
}
private static func utf16Prefix(_ value: String, limit: Int) -> String {
let units = value.utf16
guard units.count > limit else { return value }
var end = units.index(units.startIndex, offsetBy: limit)
if String.Index(end, within: value) == nil {
end = units.index(before: end)
}
guard let stringEnd = String.Index(end, within: value) else { return "" }
return String(value[..<stringEnd])
}
static func boundedSessions(_ sessions: [OpenClawChatSessionEntry]) -> [OpenClawChatSessionEntry] {
guard sessions.count > self.maxCachedSessions else { return sessions }
return Array(
@@ -961,7 +961,8 @@ extension OpenClawChatView {
id: toolCallId,
name: message.toolName,
arguments: nil,
details: message.details))
details: message.details,
isError: message.isError))
let merged = OpenClawChatMessage(
id: last.id,
@@ -976,7 +977,8 @@ extension OpenClawChatView {
usage: last.usage,
stopReason: last.stopReason,
errorMessage: last.errorMessage,
details: last.details)
details: last.details,
isError: last.isError)
result[result.count - 1] = merged
}
@@ -29,7 +29,9 @@ extension OpenClawChatViewModel {
content: content.content,
id: content.id,
name: content.name,
arguments: content.arguments)
arguments: content.arguments,
details: content.details,
isError: content.isError)
}
return OpenClawChatMessage(
@@ -42,7 +44,9 @@ extension OpenClawChatViewModel {
toolName: message.toolName,
usage: message.usage,
stopReason: message.stopReason,
errorMessage: message.errorMessage)
errorMessage: message.errorMessage,
details: message.details,
isError: message.isError)
}
static func messageContentFingerprint(for message: OpenClawChatMessage) -> String {
@@ -144,7 +148,8 @@ extension OpenClawChatViewModel {
usage: incoming.usage,
stopReason: incoming.stopReason,
errorMessage: incoming.errorMessage,
details: incoming.details)
details: incoming.details,
isError: incoming.isError)
}
private static func preservingLocalAudioDurations(
@@ -175,7 +180,9 @@ extension OpenClawChatViewModel {
content: content.content,
id: content.id,
name: content.name,
arguments: content.arguments)
arguments: content.arguments,
details: content.details,
isError: content.isError)
}
}
@@ -445,7 +452,9 @@ extension OpenClawChatViewModel {
toolName: existing.toolName,
usage: existing.usage,
stopReason: existing.stopReason,
errorMessage: existing.errorMessage)
errorMessage: existing.errorMessage,
details: existing.details,
isError: existing.isError)
}
self.replaceMessages(Self.dedupeMessages(updated))
guard let survivingIndex = self.messages.firstIndex(where: { message in
@@ -283,7 +283,8 @@ extension OpenClawChatViewModel {
usage: message.usage,
stopReason: message.stopReason,
errorMessage: message.errorMessage,
details: message.details)
details: message.details,
isError: message.isError)
}
private func handleAgentEvent(_ evt: OpenClawAgentEventPayload) {
@@ -15,6 +15,7 @@ struct ChatToolActivityTests {
arguments: nil,
details: nil,
resultText: "done",
isError: false,
isPending: false)])
}
@@ -29,6 +30,7 @@ struct ChatToolActivityTests {
arguments: nil,
details: nil,
resultText: "orphaned",
isError: false,
isPending: false)])
}
@@ -58,6 +60,7 @@ struct ChatToolActivityTests {
arguments: nil,
details: nil,
resultText: nil,
isError: false,
isPending: false)])
}
@@ -75,12 +78,30 @@ struct ChatToolActivityTests {
#expect(items.first?.details == details)
}
@Test func `threads paired and orphan result errors`() {
let paired = ChatToolActivity.items(
calls: [self.content(type: "toolCall", id: "call-1", name: "edit")],
results: [self.content(
type: "toolResult",
text: "failed",
id: "call-1",
name: "edit",
isError: true)])
let orphan = ChatToolActivity.items(
calls: [],
results: [self.content(type: "toolResult", text: "failed", isError: true)])
#expect(paired.first?.isError == true)
#expect(orphan.first?.isError == true)
}
private func content(
type: String,
text: String? = nil,
id: String? = nil,
name: String? = nil,
details: AnyCodable? = nil) -> OpenClawChatMessageContent
details: AnyCodable? = nil,
isError: Bool? = nil) -> OpenClawChatMessageContent
{
OpenClawChatMessageContent(
type: type,
@@ -90,6 +111,7 @@ struct ChatToolActivityTests {
content: nil,
id: id,
name: name,
details: details)
details: details,
isError: isError)
}
}
@@ -200,6 +200,16 @@ struct ChatToolDiffTests {
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",
@@ -208,6 +218,127 @@ struct ChatToolDiffTests {
#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 `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",
@@ -239,4 +370,20 @@ struct ChatToolDiffTests {
#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)
}
}
@@ -122,6 +122,58 @@ struct ChatTranscriptCacheStoreTests {
#expect(await store.loadTranscript(sessionKey: "unknown").isEmpty)
}
@Test func `transcript JSON keeps only bounded diff details`() async throws {
let url = try makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let oversizedDiff = "+1 " + String(repeating: "x", count: 64100)
let message = OpenClawChatMessage(
role: "toolResult",
content: [
OpenClawChatMessageContent(
type: "tool_result",
text: "done",
mimeType: nil,
fileName: nil,
content: nil,
arguments: AnyCodable(["oldText": AnyCodable("large proposal")]),
details: AnyCodable([
"diff": AnyCodable("+1 block diff"),
"ignored": AnyCodable("drop me"),
]),
isError: true),
OpenClawChatMessageContent(
type: "tool_result",
text: "no diff",
mimeType: nil,
fileName: nil,
content: nil,
details: AnyCodable(["ignored": AnyCodable("drop me")])),
],
timestamp: 1,
details: AnyCodable([
"diff": AnyCodable(oversizedDiff),
"ignored": AnyCodable("drop me"),
]),
isError: true)
let cacheable = try #require(OpenClawChatSQLiteTranscriptCache.cacheableMessages([message]).first)
#expect(cacheable.content.allSatisfy { $0.arguments == nil })
#expect(Set(cacheable.details?.dictionaryValue?.keys.map(\.self) ?? []) == ["diff"])
#expect(cacheable.details?.dictionaryValue?["diff"]?.stringValue?.utf16.count == 64000)
#expect(cacheable.details?.dictionaryValue?["diff"]?.stringValue?.hasSuffix("\n...(truncated)...") == true)
#expect(Set(cacheable.content[0].details?.dictionaryValue?.keys.map(\.self) ?? []) == ["diff"])
#expect(cacheable.content[1].details == nil)
#expect(cacheable.isError == true)
#expect(cacheable.content[0].isError == true)
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a")
await store.storeTranscript(sessionKey: "main", messages: [message])
let decoded = try #require(await store.loadTranscript(sessionKey: "main").first)
#expect(decoded.details == cacheable.details)
#expect(decoded.content[0].details == cacheable.content[0].details)
#expect(ChatToolDiff.resolveDiff(name: "edit", arguments: nil, details: decoded.details)?.stat == nil)
}
@Test func `transcript keeps only most recent messages within bound`() async throws {
let url = try makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }