mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
c7d2d111d8
commit
e0ecdc47be
@@ -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: §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[..<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: §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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+14
-5
@@ -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()) }
|
||||
|
||||
Reference in New Issue
Block a user