diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 7aff760b5006..79e654bc4cfe 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index f423f260153c..c7db8e70cff1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -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) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift index 24881ac6334b..d5362383c0d1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift @@ -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) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift index a95a66ed745f..321280a381bd 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift @@ -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 } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolDiff.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolDiff.swift index dd5e2df08e33..495666f788bc 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolDiff.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolDiff.swift @@ -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: §ion, + 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: §ion, + 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: §ion, + storedRows: &storedRows, + clipped: &clipped) + } else if section.operation == .update, + raw.isEmpty || raw.hasPrefix("+") || raw.hasPrefix("-") || raw.hasPrefix(" ") + { + self.pushPatchHunkLine( + raw, + hunk: &hunk, + section: §ion, + 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[.. 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: §ion, + 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 } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift index d1b6cffed98e..209b6b9987fb 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift @@ -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[.. [OpenClawChatSessionEntry] { guard sessions.count > self.maxCachedSessions else { return sessions } return Array( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index ff853786af23..c4127332af99 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -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 } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift index 6ccf5cde0a5b..76985ef3a352 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift @@ -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 diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift index 43396aac6d53..c6e1f41eda68 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift @@ -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) { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolActivityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolActivityTests.swift index d29d9a0bea66..31cb15522d0e 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolActivityTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolActivityTests.swift @@ -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) } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolDiffTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolDiffTests.swift index 13b780257a17..bea3aa586937 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolDiffTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolDiffTests.swift @@ -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) + } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift index 617338ab6668..845cec134704 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift @@ -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()) }