mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(apps): Apple chat adopts the system-notice family (#122255)
* feat(apps): adopt the system-notice family in Apple chat (OpenClawChatUI) Decode internal_system provenance and __openclaw history markers, classify once into message/notice/divider rows (web-parity kinds), render minimal hairline notice/divider SwiftUI rows, preserve metadata through history reconciliation, live transport, and the transcript cache, and align the transcript exporter with visible classification instead of leaking raw [System] prompts. * fix(apps): satisfy OpenClawKit periphery gate Remove the dead visibleMessages projection (rows path replaced its consumers) and annotate the provenance test-fixture initializer with the repo-standard periphery:ignore rationale. * fix(apps): refresh native i18n inventory after periphery cleanup
This commit is contained in:
committed by
GitHub
parent
edb7a1692e
commit
cb51cf7d3e
+100
-36
@@ -40083,7 +40083,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 247,
|
||||
"line": 228,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift",
|
||||
"source": "Context usage",
|
||||
"surface": "apple",
|
||||
@@ -40091,7 +40091,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 261,
|
||||
"line": 242,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift",
|
||||
"source": "%@ percent of the context window used",
|
||||
"surface": "apple",
|
||||
@@ -40099,7 +40099,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 265,
|
||||
"line": 246,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift",
|
||||
"source": "%@ tokens used",
|
||||
"surface": "apple",
|
||||
@@ -40435,7 +40435,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 39,
|
||||
"line": 124,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "%@ avatar",
|
||||
"surface": "apple",
|
||||
@@ -40443,7 +40443,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 41,
|
||||
"line": 126,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Agent avatar",
|
||||
"surface": "apple",
|
||||
@@ -40451,7 +40451,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 401,
|
||||
"line": 486,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Additional images hidden: %lld",
|
||||
"surface": "apple",
|
||||
@@ -40459,7 +40459,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 441,
|
||||
"line": 526,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Show less",
|
||||
"surface": "apple",
|
||||
@@ -40467,7 +40467,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 441,
|
||||
"line": 526,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Show more",
|
||||
"surface": "apple",
|
||||
@@ -40475,7 +40475,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 455,
|
||||
"line": 540,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Collapsed",
|
||||
"surface": "apple",
|
||||
@@ -40483,7 +40483,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 455,
|
||||
"line": 540,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Expanded",
|
||||
"surface": "apple",
|
||||
@@ -40491,7 +40491,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 475,
|
||||
"line": 560,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Message usage",
|
||||
"surface": "apple",
|
||||
@@ -40499,7 +40499,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 702,
|
||||
"line": 787,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Voice note",
|
||||
"surface": "apple",
|
||||
@@ -40507,7 +40507,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 743,
|
||||
"line": 828,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Attachment",
|
||||
"surface": "apple",
|
||||
@@ -40515,7 +40515,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 783,
|
||||
"line": 868,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Writing",
|
||||
"surface": "apple",
|
||||
@@ -40523,7 +40523,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 822,
|
||||
"line": 907,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Preparing audio…",
|
||||
"surface": "apple",
|
||||
@@ -40531,7 +40531,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 825,
|
||||
"line": 910,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Speaking…",
|
||||
"surface": "apple",
|
||||
@@ -40539,7 +40539,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 833,
|
||||
"line": 918,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Preparing audio, tap to cancel",
|
||||
"surface": "apple",
|
||||
@@ -40547,7 +40547,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 834,
|
||||
"line": 919,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
|
||||
"source": "Speaking, tap to stop",
|
||||
"surface": "apple",
|
||||
@@ -41715,7 +41715,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 80,
|
||||
"line": 90,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift",
|
||||
"source": "Chat transcript",
|
||||
"surface": "apple",
|
||||
@@ -41723,7 +41723,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 102,
|
||||
"line": 112,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift",
|
||||
"source": "Attachment",
|
||||
"surface": "apple",
|
||||
@@ -41731,15 +41731,79 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 122,
|
||||
"line": 144,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift",
|
||||
"source": "Message",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.0149100bb5c8b985"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 19,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
|
||||
"source": "System · restart recovery",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.041f2ed87f1797a0"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 21,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
|
||||
"source": "System · gateway restarted",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.0eaa9e1136018332"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 23,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
|
||||
"source": "System",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.25ffcf5fcb1645d3"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 46,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
|
||||
"source": "Compacted history",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.55c9f51c550018a9"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 48,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
|
||||
"source": "Session reset",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.28264f2dcf55651b"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 55,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
|
||||
"source": "saved %@ tokens",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.6410db19dcb02d59"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 64,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
|
||||
"source": "The earlier conversation was cleared.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.0b97fe0abe7ea144"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call-multiline",
|
||||
"line": 132,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift",
|
||||
"source": "Turn interrupted by a gateway restart — asked the agent to resume and finish the response.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.4efcef39bcaea3d1"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 592,
|
||||
"line": 601,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Retry Send",
|
||||
"surface": "apple",
|
||||
@@ -41747,7 +41811,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 606,
|
||||
"line": 615,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Delete",
|
||||
"surface": "apple",
|
||||
@@ -41755,7 +41819,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 642,
|
||||
"line": 651,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Stop Listening",
|
||||
"surface": "apple",
|
||||
@@ -41763,7 +41827,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 645,
|
||||
"line": 654,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Listen",
|
||||
"surface": "apple",
|
||||
@@ -41771,7 +41835,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 742,
|
||||
"line": 750,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Jump to latest reply",
|
||||
"surface": "apple",
|
||||
@@ -41779,7 +41843,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 762,
|
||||
"line": 770,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Refresh",
|
||||
"surface": "apple",
|
||||
@@ -41787,7 +41851,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1175,
|
||||
"line": 1181,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Copy Message",
|
||||
"surface": "apple",
|
||||
@@ -41795,7 +41859,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1198,
|
||||
"line": 1204,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Open Full Message",
|
||||
"surface": "apple",
|
||||
@@ -41803,7 +41867,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1217,
|
||||
"line": 1223,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Rewind to Here",
|
||||
"surface": "apple",
|
||||
@@ -41811,7 +41875,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1237,
|
||||
"line": 1243,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Fork from Here",
|
||||
"surface": "apple",
|
||||
@@ -41819,7 +41883,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1263,
|
||||
"line": 1269,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Reply",
|
||||
"surface": "apple",
|
||||
@@ -41827,7 +41891,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1273,
|
||||
"line": 1279,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "You",
|
||||
"surface": "apple",
|
||||
@@ -41835,7 +41899,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1275,
|
||||
"line": 1281,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Assistant",
|
||||
"surface": "apple",
|
||||
@@ -41843,7 +41907,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1341,
|
||||
"line": 1347,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Loading chat",
|
||||
"surface": "apple",
|
||||
@@ -41851,7 +41915,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 1421,
|
||||
"line": 1427,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Dismiss",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
enum ChatCompactTokenCountFormatter {
|
||||
static func string(_ tokens: Double) -> String {
|
||||
if tokens >= 1_000_000 {
|
||||
return "\(self.oneDecimal(tokens / 1_000_000))M"
|
||||
}
|
||||
if tokens >= 1000 {
|
||||
let thousands = self.oneDecimal(tokens / 1000)
|
||||
if Double(thousands) ?? 0 >= 1000 {
|
||||
return "\(self.oneDecimal(tokens / 1_000_000))M"
|
||||
}
|
||||
return "\(thousands)k"
|
||||
}
|
||||
return String(Int(tokens))
|
||||
}
|
||||
|
||||
private static func oneDecimal(_ value: Double) -> String {
|
||||
let rounded = (value * 10).rounded(.toNearestOrAwayFromZero) / 10
|
||||
let formatted = String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), rounded)
|
||||
return formatted.hasSuffix(".0") ? String(formatted.dropLast(2)) : formatted
|
||||
}
|
||||
}
|
||||
@@ -118,25 +118,25 @@ struct ChatMessageUsagePresentation: Equatable {
|
||||
let cacheWrite = self.positive(usage.cacheWrite)
|
||||
|
||||
if let input {
|
||||
visualParts.append("↑\(self.tokens(input))")
|
||||
visualParts.append("↑\(ChatCompactTokenCountFormatter.string(Double(input)))")
|
||||
accessibilityParts.append(String(
|
||||
format: String(localized: "Input tokens: %@"),
|
||||
input.formatted()))
|
||||
}
|
||||
if let output {
|
||||
visualParts.append("↓\(self.tokens(output))")
|
||||
visualParts.append("↓\(ChatCompactTokenCountFormatter.string(Double(output)))")
|
||||
accessibilityParts.append(String(
|
||||
format: String(localized: "Output tokens: %@"),
|
||||
output.formatted()))
|
||||
}
|
||||
if let cacheRead {
|
||||
visualParts.append("R\(self.tokens(cacheRead))")
|
||||
visualParts.append("R\(ChatCompactTokenCountFormatter.string(Double(cacheRead)))")
|
||||
accessibilityParts.append(String(
|
||||
format: String(localized: "Cache read tokens: %@"),
|
||||
cacheRead.formatted()))
|
||||
}
|
||||
if let cacheWrite {
|
||||
visualParts.append("W\(self.tokens(cacheWrite))")
|
||||
visualParts.append("W\(ChatCompactTokenCountFormatter.string(Double(cacheWrite)))")
|
||||
accessibilityParts.append(String(
|
||||
format: String(localized: "Cache write tokens: %@"),
|
||||
cacheWrite.formatted()))
|
||||
@@ -197,25 +197,6 @@ struct ChatMessageUsagePresentation: Equatable {
|
||||
guard let value, value > 0 else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func tokens(_ value: Int) -> String {
|
||||
if value >= 1_000_000 {
|
||||
return "\(self.trimmedDecimal(Double(value) / 1_000_000))M"
|
||||
}
|
||||
if value >= 1000 {
|
||||
let thousands = Double(value) / 1000
|
||||
if thousands >= 999.95 {
|
||||
return "\(self.trimmedDecimal(Double(value) / 1_000_000))M"
|
||||
}
|
||||
return "\(self.trimmedDecimal(thousands))k"
|
||||
}
|
||||
return "\(value)"
|
||||
}
|
||||
|
||||
private static func trimmedDecimal(_ value: Double) -> String {
|
||||
String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), value)
|
||||
.replacingOccurrences(of: ".0", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
@@ -2,6 +2,91 @@ import Foundation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct ChatSystemNoticeRow: View {
|
||||
let notice: ChatTranscriptRow.SystemNotice
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
ChatSystemLine(
|
||||
systemImage: self.notice.systemImage,
|
||||
label: self.notice.label,
|
||||
metric: nil)
|
||||
Text(self.notice.body)
|
||||
.font(OpenClawChatTypography.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 4)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Text(verbatim: "\(self.notice.label), \(self.notice.body)"))
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatHistoryDividerRow: View {
|
||||
let divider: ChatTranscriptRow.HistoryDivider
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
ChatSystemLine(
|
||||
systemImage: self.divider.systemImage,
|
||||
label: self.divider.label,
|
||||
metric: self.divider.metric)
|
||||
if let description = self.divider.description {
|
||||
Text(description)
|
||||
.font(OpenClawChatTypography.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 4)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(self.accessibilityLabel)
|
||||
}
|
||||
|
||||
private var accessibilityLabel: String {
|
||||
[self.divider.label, self.divider.metric, self.divider.description]
|
||||
.compactMap(\.self)
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatSystemLine: View {
|
||||
let systemImage: String
|
||||
let label: String
|
||||
let metric: String?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Rectangle()
|
||||
.fill(OpenClawChatTheme.divider)
|
||||
.frame(height: 1)
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.accessibilityHidden(true)
|
||||
Text(self.label.uppercased())
|
||||
.font(OpenClawChatTypography.captionSemiBold)
|
||||
.tracking(0.5)
|
||||
if let metric {
|
||||
Text("·")
|
||||
.font(OpenClawChatTypography.caption)
|
||||
.accessibilityHidden(true)
|
||||
Text(metric)
|
||||
.font(OpenClawChatTypography.caption)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
Rectangle()
|
||||
.fill(OpenClawChatTheme.divider)
|
||||
.frame(height: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ChatUIConstants {
|
||||
static let bubbleMaxWidth: CGFloat = 560
|
||||
static let bubbleCorner: CGFloat = 18
|
||||
|
||||
@@ -362,11 +362,51 @@ public struct OpenClawChatCanvasPreview: Codable, Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawChatInputProvenance: Codable, Hashable, Sendable {
|
||||
public let kind: String
|
||||
public let originSessionId: String?
|
||||
public let sourceSessionKey: String?
|
||||
public let sourceChannel: String?
|
||||
public let sourceTool: String?
|
||||
|
||||
// periphery:ignore - package tests construct provenance fixtures; app consumers decode this payload.
|
||||
public init(
|
||||
kind: String,
|
||||
originSessionId: String? = nil,
|
||||
sourceSessionKey: String? = nil,
|
||||
sourceChannel: String? = nil,
|
||||
sourceTool: String? = nil)
|
||||
{
|
||||
self.kind = kind
|
||||
self.originSessionId = originSessionId
|
||||
self.sourceSessionKey = sourceSessionKey
|
||||
self.sourceChannel = sourceChannel
|
||||
self.sourceTool = sourceTool
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawChatHistoryMarker: Codable, Hashable, Sendable {
|
||||
public let kind: String
|
||||
public let id: String?
|
||||
public let tokensBefore: Double?
|
||||
public let tokensAfter: Double?
|
||||
|
||||
public init(kind: String, id: String? = nil, tokensBefore: Double? = nil, tokensAfter: Double? = nil) {
|
||||
self.kind = kind
|
||||
self.id = id
|
||||
self.tokensBefore = tokensBefore
|
||||
self.tokensAfter = tokensAfter
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
|
||||
private struct OpenClawMetadata: Codable {
|
||||
let kind: String?
|
||||
let id: String?
|
||||
let idempotencyKey: String?
|
||||
let truncated: Bool?
|
||||
let tokensBefore: Double?
|
||||
let tokensAfter: Double?
|
||||
}
|
||||
|
||||
public var id: UUID = .init()
|
||||
@@ -383,6 +423,8 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
|
||||
public let errorMessage: String?
|
||||
public let details: AnyCodable?
|
||||
public let isError: Bool?
|
||||
public let provenance: OpenClawChatInputProvenance?
|
||||
public let historyMarker: OpenClawChatHistoryMarker?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case role
|
||||
@@ -390,6 +432,7 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
|
||||
case timestamp
|
||||
case idempotencyKey
|
||||
case openClaw = "__openclaw"
|
||||
case provenance
|
||||
case toolCallId
|
||||
case tool_call_id
|
||||
case toolName
|
||||
@@ -420,7 +463,9 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
|
||||
stopReason: String? = nil,
|
||||
errorMessage: String? = nil,
|
||||
details: AnyCodable? = nil,
|
||||
isError: Bool? = nil)
|
||||
isError: Bool? = nil,
|
||||
provenance: OpenClawChatInputProvenance? = nil,
|
||||
historyMarker: OpenClawChatHistoryMarker? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.transcriptMessageID = transcriptMessageID
|
||||
@@ -436,6 +481,8 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
|
||||
self.errorMessage = errorMessage
|
||||
self.details = details
|
||||
self.isError = isError
|
||||
self.provenance = provenance
|
||||
self.historyMarker = historyMarker
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
@@ -457,6 +504,9 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
|
||||
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)
|
||||
let decodedProvenance = try? container.decode(
|
||||
OpenClawChatInputProvenance.self,
|
||||
forKey: .provenance)
|
||||
|
||||
self.role = decodedRole
|
||||
self.transcriptMessageID = decodedOpenClaw?.id
|
||||
@@ -469,6 +519,14 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
|
||||
self.errorMessage = decodedErrorMessage
|
||||
self.details = decodedDetails
|
||||
self.isError = decodedIsError
|
||||
self.provenance = decodedProvenance
|
||||
self.historyMarker = decodedOpenClaw?.kind.map {
|
||||
OpenClawChatHistoryMarker(
|
||||
kind: $0,
|
||||
id: decodedOpenClaw?.id,
|
||||
tokensBefore: decodedOpenClaw?.tokensBefore,
|
||||
tokensAfter: decodedOpenClaw?.tokensAfter)
|
||||
}
|
||||
|
||||
let decodedContent: [OpenClawChatMessageContent] = if let decoded = try? container.decode(
|
||||
[OpenClawChatMessageContent].self,
|
||||
@@ -564,14 +622,18 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(self.role, forKey: .role)
|
||||
try container.encodeIfPresent(self.timestamp, forKey: .timestamp)
|
||||
if self.transcriptMessageID != nil || self.isTruncated {
|
||||
if self.transcriptMessageID != nil || self.isTruncated || self.historyMarker != nil {
|
||||
try container.encode(
|
||||
OpenClawMetadata(
|
||||
id: self.transcriptMessageID,
|
||||
kind: self.historyMarker?.kind,
|
||||
id: self.historyMarker?.id ?? self.transcriptMessageID,
|
||||
idempotencyKey: nil,
|
||||
truncated: self.isTruncated ? true : nil),
|
||||
truncated: self.isTruncated ? true : nil,
|
||||
tokensBefore: self.historyMarker?.tokensBefore,
|
||||
tokensAfter: self.historyMarker?.tokensAfter),
|
||||
forKey: .openClaw)
|
||||
}
|
||||
try container.encodeIfPresent(self.provenance, forKey: .provenance)
|
||||
try container.encodeIfPresent(self.idempotencyKey, forKey: .idempotencyKey)
|
||||
try container.encodeIfPresent(self.toolCallId, forKey: .toolCallId)
|
||||
try container.encodeIfPresent(self.toolName, forKey: .toolName)
|
||||
|
||||
@@ -1421,6 +1421,8 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
isError: item.isError)
|
||||
},
|
||||
timestamp: message.timestamp,
|
||||
transcriptMessageID: message.transcriptMessageID,
|
||||
isTruncated: message.isTruncated,
|
||||
idempotencyKey: message.idempotencyKey,
|
||||
toolCallId: message.toolCallId,
|
||||
toolName: message.toolName,
|
||||
@@ -1428,7 +1430,9 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
stopReason: message.stopReason,
|
||||
errorMessage: message.errorMessage,
|
||||
details: self.cacheableDetails(message.details),
|
||||
isError: message.isError)
|
||||
isError: message.isError,
|
||||
provenance: message.provenance,
|
||||
historyMarker: message.historyMarker)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,21 @@ public enum ChatTranscriptExporter {
|
||||
timestampFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
|
||||
var sections = ["# \(title)"]
|
||||
for message in messages where self.shouldExport(message) {
|
||||
let timestamp = self.timestamp(message.timestamp, formatter: timestampFormatter)
|
||||
let heading = "### \(self.displayRole(message.role)) — \(timestamp)"
|
||||
let body = self.body(for: message)
|
||||
sections.append([heading, body].filter { !$0.isEmpty }.joined(separator: "\n\n"))
|
||||
for row in ChatTranscriptRow.build(from: messages) {
|
||||
switch row {
|
||||
case let .message(message) where self.shouldExport(message):
|
||||
let timestamp = self.timestamp(message.timestamp, formatter: timestampFormatter)
|
||||
let heading = "### \(self.displayRole(message.role)) — \(timestamp)"
|
||||
let body = self.body(for: message)
|
||||
sections.append([heading, body].filter { !$0.isEmpty }.joined(separator: "\n\n"))
|
||||
case .message:
|
||||
continue
|
||||
case let .systemNotice(notice):
|
||||
let timestamp = self.timestamp(notice.timestamp, formatter: timestampFormatter)
|
||||
sections.append("### System — \(timestamp)\n\n[\(notice.label)] \(notice.body)")
|
||||
case let .historyDivider(divider):
|
||||
sections.append(self.dividerLine(divider))
|
||||
}
|
||||
}
|
||||
return sections.joined(separator: "\n\n") + "\n"
|
||||
}
|
||||
@@ -104,6 +114,18 @@ public enum ChatTranscriptExporter {
|
||||
return parts.joined(separator: "\n\n")
|
||||
}
|
||||
|
||||
private static func dividerLine(_ divider: ChatTranscriptRow.HistoryDivider) -> String {
|
||||
switch divider.kind {
|
||||
case .compaction:
|
||||
let text = [divider.label, divider.metric]
|
||||
.compactMap(\.self)
|
||||
.joined(separator: " · ")
|
||||
return "[\(text)]"
|
||||
case .reset:
|
||||
return "[\(divider.label) — \(divider.description ?? "")]"
|
||||
}
|
||||
}
|
||||
|
||||
private static func visibleText(in message: OpenClawChatMessage) -> String {
|
||||
ChatMessageVisibleText.visibleText(in: message)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
|
||||
enum ChatTranscriptRow: Hashable, Identifiable {
|
||||
enum SystemNoticeKind: Hashable {
|
||||
case restartRecovery
|
||||
case gatewayRestarted
|
||||
case generic
|
||||
}
|
||||
|
||||
struct SystemNotice: Hashable {
|
||||
let id: UUID
|
||||
let kind: SystemNoticeKind
|
||||
let body: String
|
||||
let timestamp: Double?
|
||||
|
||||
var label: String {
|
||||
switch self.kind {
|
||||
case .restartRecovery:
|
||||
String(localized: "System · restart recovery")
|
||||
case .gatewayRestarted:
|
||||
String(localized: "System · gateway restarted")
|
||||
case .generic:
|
||||
String(localized: "System")
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
"cpu"
|
||||
}
|
||||
}
|
||||
|
||||
enum HistoryDividerKind: Hashable {
|
||||
case compaction
|
||||
case reset
|
||||
}
|
||||
|
||||
struct HistoryDivider: Hashable {
|
||||
let id: UUID
|
||||
let kind: HistoryDividerKind
|
||||
let savedTokens: Double?
|
||||
let timestamp: Double?
|
||||
|
||||
var label: String {
|
||||
switch self.kind {
|
||||
case .compaction:
|
||||
String(localized: "Compacted history")
|
||||
case .reset:
|
||||
String(localized: "Session reset")
|
||||
}
|
||||
}
|
||||
|
||||
var metric: String? {
|
||||
guard self.kind == .compaction, let savedTokens else { return nil }
|
||||
return String(
|
||||
format: String(localized: "saved %@ tokens"),
|
||||
ChatCompactTokenCountFormatter.string(savedTokens))
|
||||
}
|
||||
|
||||
var description: String? {
|
||||
switch self.kind {
|
||||
case .compaction:
|
||||
nil
|
||||
case .reset:
|
||||
String(localized: "The earlier conversation was cleared.")
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self.kind {
|
||||
case .compaction:
|
||||
"rectangle.compress.vertical"
|
||||
case .reset:
|
||||
"arrow.counterclockwise"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case message(OpenClawChatMessage)
|
||||
case systemNotice(SystemNotice)
|
||||
case historyDivider(HistoryDivider)
|
||||
|
||||
var id: UUID {
|
||||
switch self {
|
||||
case let .message(message): message.id
|
||||
case let .systemNotice(notice): notice.id
|
||||
case let .historyDivider(divider): divider.id
|
||||
}
|
||||
}
|
||||
|
||||
var startsTurn: Bool {
|
||||
switch self {
|
||||
case let .message(message):
|
||||
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
|
||||
case .systemNotice:
|
||||
true
|
||||
case .historyDivider:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
init?(_ message: OpenClawChatMessage) {
|
||||
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if role == "system", let marker = message.historyMarker {
|
||||
switch marker.kind {
|
||||
case "compaction":
|
||||
let savedTokens = Self.savedTokens(for: marker)
|
||||
self = .historyDivider(HistoryDivider(
|
||||
id: message.id,
|
||||
kind: .compaction,
|
||||
savedTokens: savedTokens,
|
||||
timestamp: message.timestamp))
|
||||
case "reset":
|
||||
self = .historyDivider(HistoryDivider(
|
||||
id: message.id,
|
||||
kind: .reset,
|
||||
savedTokens: nil,
|
||||
timestamp: message.timestamp))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if role == "user", message.provenance?.kind == "internal_system" {
|
||||
let kind: SystemNoticeKind
|
||||
let body: String
|
||||
switch message.provenance?.sourceTool {
|
||||
case "main_session_restart_recovery":
|
||||
kind = .restartRecovery
|
||||
body =
|
||||
String(
|
||||
localized: """
|
||||
Turn interrupted by a gateway restart — asked the agent to resume and finish the response.
|
||||
""")
|
||||
case "restart-sentinel":
|
||||
kind = .gatewayRestarted
|
||||
body = Self.strippingSystemPrefix(from: ChatMessageVisibleText.visibleText(in: message))
|
||||
default:
|
||||
kind = .generic
|
||||
body = Self.strippingSystemPrefix(from: ChatMessageVisibleText.visibleText(in: message))
|
||||
}
|
||||
guard !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
||||
self = .systemNotice(SystemNotice(
|
||||
id: message.id,
|
||||
kind: kind,
|
||||
body: body,
|
||||
timestamp: message.timestamp))
|
||||
return
|
||||
}
|
||||
|
||||
self = .message(message)
|
||||
}
|
||||
|
||||
static func build(from messages: [OpenClawChatMessage]) -> [Self] {
|
||||
messages.compactMap(Self.init)
|
||||
}
|
||||
|
||||
private static func savedTokens(for marker: OpenClawChatHistoryMarker) -> Double? {
|
||||
guard let before = marker.tokensBefore,
|
||||
before.isFinite,
|
||||
let after = marker.tokensAfter,
|
||||
after.isFinite,
|
||||
before > after
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return floor(before - after)
|
||||
}
|
||||
|
||||
private static func strippingSystemPrefix(from text: String) -> String {
|
||||
let prefix = "[System] "
|
||||
return text.hasPrefix(prefix) ? String(text.dropFirst(prefix.count)) : text
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
|
||||
case empty
|
||||
case loading
|
||||
case error
|
||||
case systemNotices
|
||||
}
|
||||
|
||||
let scenario: Scenario
|
||||
@@ -38,6 +39,31 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
|
||||
domain: "OpenClawChatPreviewTransport",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Gateway not connected. Check Tailscale and retry."])
|
||||
case .systemNotices:
|
||||
return OpenClawChatHistoryPayload(
|
||||
sessionKey: sessionKey,
|
||||
sessionId: "preview-system-notices",
|
||||
messages: [
|
||||
Self.systemNotice(
|
||||
text: "[System] Resume the interrupted turn with internal recovery context.",
|
||||
sourceTool: "main_session_restart_recovery",
|
||||
timestamp: 1),
|
||||
Self.systemNotice(
|
||||
text: "[System] Gateway restarted after installing an update.",
|
||||
sourceTool: "restart-sentinel",
|
||||
timestamp: 2),
|
||||
Self.historyMarker(
|
||||
kind: "compaction",
|
||||
id: "preview-compaction",
|
||||
timestamp: 3,
|
||||
tokensBefore: 48000,
|
||||
tokensAfter: 19500),
|
||||
Self.historyMarker(
|
||||
kind: "reset",
|
||||
id: "preview-reset",
|
||||
timestamp: 4),
|
||||
],
|
||||
thinkingLevel: "medium")
|
||||
}
|
||||
|
||||
return OpenClawChatHistoryPayload(
|
||||
@@ -121,7 +147,7 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
|
||||
|
||||
func requestHealth(timeoutMs _: Int) async throws -> Bool {
|
||||
switch self.scenario {
|
||||
case .connected, .empty, .loading:
|
||||
case .connected, .empty, .loading, .systemNotices:
|
||||
true
|
||||
case .error:
|
||||
false
|
||||
@@ -144,6 +170,36 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
|
||||
])
|
||||
}
|
||||
|
||||
private static func systemNotice(text: String, sourceTool: String, timestamp: Double) -> AnyCodable {
|
||||
AnyCodable([
|
||||
"role": "user",
|
||||
"content": [["type": "text", "text": text]],
|
||||
"timestamp": timestamp,
|
||||
"provenance": [
|
||||
"kind": "internal_system",
|
||||
"sourceTool": sourceTool,
|
||||
],
|
||||
])
|
||||
}
|
||||
|
||||
private static func historyMarker(
|
||||
kind: String,
|
||||
id: String,
|
||||
timestamp: Double,
|
||||
tokensBefore: Double? = nil,
|
||||
tokensAfter: Double? = nil) -> AnyCodable
|
||||
{
|
||||
var marker: [String: Any] = ["kind": kind, "id": id]
|
||||
marker["tokensBefore"] = tokensBefore
|
||||
marker["tokensAfter"] = tokensAfter
|
||||
return AnyCodable([
|
||||
"role": "system",
|
||||
"content": [],
|
||||
"timestamp": timestamp,
|
||||
"__openclaw": marker,
|
||||
])
|
||||
}
|
||||
|
||||
private static func toolCall(
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -234,6 +290,12 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
|
||||
sessionKey: "error-preview")
|
||||
}
|
||||
|
||||
#Preview("System notices") {
|
||||
OpenClawChatPreview(
|
||||
scenario: .systemNotices,
|
||||
sessionKey: "system-notices-preview")
|
||||
}
|
||||
|
||||
#Preview("Onboarding chat") {
|
||||
OpenClawChatView(
|
||||
viewModel: OpenClawChatViewModel(
|
||||
|
||||
@@ -64,7 +64,7 @@ func chatReaderScrollReleasesFollow(_ phase: ScrollPhase) -> Bool {
|
||||
|
||||
private enum ScrollFollowTarget: Equatable {
|
||||
case latest
|
||||
case user(UUID)
|
||||
case turn(UUID)
|
||||
}
|
||||
|
||||
private struct ChatTurnRecapObservation: Equatable {
|
||||
@@ -118,7 +118,7 @@ public struct OpenClawChatView: View {
|
||||
@State private var scrollerBottomID = UUID()
|
||||
@State private var scrollPosition: UUID?
|
||||
@State private var hasPerformedInitialScroll = false
|
||||
@State private var lastUserMessageID: UUID?
|
||||
@State private var lastTurnStartID: UUID?
|
||||
@State private var hasNewerContentBelow = false
|
||||
@State private var followTarget: ScrollFollowTarget? = .latest
|
||||
@State private var isAtLiveEdge = true
|
||||
@@ -380,7 +380,7 @@ public struct OpenClawChatView: View {
|
||||
} action: { _, isAtLiveEdge in
|
||||
self.isAtLiveEdge = isAtLiveEdge
|
||||
guard self.hasPerformedInitialScroll else { return }
|
||||
if isAtLiveEdge, !self.isUserScrolling, !self.isFollowingUserTurn {
|
||||
if isAtLiveEdge, !self.isUserScrolling, !self.isFollowingTurn {
|
||||
self.followTarget = .latest
|
||||
self.hasNewerContentBelow = false
|
||||
}
|
||||
@@ -427,7 +427,7 @@ public struct OpenClawChatView: View {
|
||||
guard !isLoading, !self.hasPerformedInitialScroll else { return }
|
||||
self.restoreInitialScrollPosition()
|
||||
self.hasPerformedInitialScroll = true
|
||||
self.lastUserMessageID = self.latestVisibleUserMessageID
|
||||
self.lastTurnStartID = self.latestVisibleTurnStartID
|
||||
}
|
||||
.onChange(of: self.viewModel.sessionKey) { _, _ in
|
||||
self.speech?.stop()
|
||||
@@ -436,7 +436,7 @@ public struct OpenClawChatView: View {
|
||||
self.isAtLiveEdge = true
|
||||
self.isUserScrolling = false
|
||||
self.hasNewerContentBelow = false
|
||||
self.lastUserMessageID = nil
|
||||
self.lastTurnStartID = nil
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
if newValue == .background {
|
||||
@@ -481,8 +481,17 @@ public struct OpenClawChatView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
ForEach(self.visibleMessages) { msg in
|
||||
self.messageRow(for: msg, contextWindowTokens: contextWindowTokens)
|
||||
ForEach(self.transcriptRows) { row in
|
||||
switch row {
|
||||
case let .message(message):
|
||||
self.messageRow(for: message, contextWindowTokens: contextWindowTokens)
|
||||
case let .systemNotice(notice):
|
||||
ChatSystemNoticeRow(notice: notice)
|
||||
.frame(maxWidth: .infinity)
|
||||
case let .historyDivider(divider):
|
||||
ChatHistoryDividerRow(divider: divider)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
OpenClawQuestionCards(viewModel: self.viewModel)
|
||||
@@ -681,7 +690,7 @@ public struct OpenClawChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var visibleMessages: [OpenClawChatMessage] {
|
||||
private var transcriptRows: [ChatTranscriptRow] {
|
||||
let base: [OpenClawChatMessage]
|
||||
if self.style == .onboarding {
|
||||
guard let first = viewModel.messages.first else { return [] }
|
||||
@@ -690,23 +699,22 @@ public struct OpenClawChatView: View {
|
||||
} else {
|
||||
base = self.viewModel.messages
|
||||
}
|
||||
return self.mergeToolResults(in: base).filter(self.shouldDisplayMessage(_:))
|
||||
}
|
||||
|
||||
private var latestVisibleUserMessageID: UUID? {
|
||||
self.visibleUserMessageIDs.last
|
||||
}
|
||||
|
||||
private var visibleUserMessageIDs: [UUID] {
|
||||
self.visibleMessages.compactMap { message in
|
||||
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
|
||||
? message.id
|
||||
: nil
|
||||
return ChatTranscriptRow.build(from: self.mergeToolResults(in: base)).filter { row in
|
||||
guard case let .message(message) = row else { return true }
|
||||
return self.shouldDisplayMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
private var isFollowingUserTurn: Bool {
|
||||
if case .user = self.followTarget {
|
||||
private var latestVisibleTurnStartID: UUID? {
|
||||
self.visibleTurnStartIDs.last
|
||||
}
|
||||
|
||||
private var visibleTurnStartIDs: [UUID] {
|
||||
self.transcriptRows.compactMap { $0.startsTurn ? $0.id : nil }
|
||||
}
|
||||
|
||||
private var isFollowingTurn: Bool {
|
||||
if case .turn = self.followTarget {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -787,7 +795,7 @@ public struct OpenClawChatView: View {
|
||||
}
|
||||
|
||||
private var hasVisibleMessageListContent: Bool {
|
||||
if !self.visibleMessages.isEmpty {
|
||||
if !self.transcriptRows.isEmpty {
|
||||
return true
|
||||
}
|
||||
return self.hasVisibleTransientContent
|
||||
@@ -918,13 +926,13 @@ public struct OpenClawChatView: View {
|
||||
}
|
||||
|
||||
private func restoreInitialScrollPosition() {
|
||||
if let latestUserMessageID = latestVisibleUserMessageID {
|
||||
if let latestTurnStartID = latestVisibleTurnStartID {
|
||||
self.followTarget = nil
|
||||
self.hasNewerContentBelow = chatReaderHasNewerContent(
|
||||
after: latestUserMessageID,
|
||||
visibleIDs: self.visibleMessages.map(\.id),
|
||||
after: latestTurnStartID,
|
||||
visibleIDs: self.transcriptRows.map(\.id),
|
||||
hasTransientContent: self.hasVisibleTransientContent)
|
||||
self.moveScrollPosition(to: latestUserMessageID, anchor: Layout.newTurnAnchor)
|
||||
self.moveScrollPosition(to: latestTurnStartID, anchor: Layout.newTurnAnchor)
|
||||
} else {
|
||||
self.followTarget = .latest
|
||||
self.hasNewerContentBelow = false
|
||||
@@ -940,33 +948,29 @@ public struct OpenClawChatView: View {
|
||||
self.viewModel.pendingToolCalls.isEmpty,
|
||||
self.viewModel.streamingAssistantText == nil
|
||||
{
|
||||
self.lastUserMessageID = nil
|
||||
self.lastTurnStartID = nil
|
||||
self.followTarget = .latest
|
||||
self.hasNewerContentBelow = false
|
||||
self.moveScrollPosition(to: self.scrollerBottomID)
|
||||
return
|
||||
}
|
||||
let visibleMessages = self.visibleMessages
|
||||
let visibleUserMessageIDs = visibleMessages.compactMap { message in
|
||||
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
|
||||
? message.id
|
||||
: nil
|
||||
}
|
||||
let transcriptRows = self.transcriptRows
|
||||
let visibleTurnStartIDs = transcriptRows.compactMap { $0.startsTurn ? $0.id : nil }
|
||||
switch chatReaderUserTransition(
|
||||
previousID: self.lastUserMessageID,
|
||||
visibleIDs: visibleUserMessageIDs)
|
||||
previousID: self.lastTurnStartID,
|
||||
visibleIDs: visibleTurnStartIDs)
|
||||
{
|
||||
case let .removed(latestRemainingID):
|
||||
self.lastUserMessageID = latestRemainingID
|
||||
if case let .user(messageID) = followTarget,
|
||||
!visibleUserMessageIDs.contains(messageID)
|
||||
self.lastTurnStartID = latestRemainingID
|
||||
if case let .turn(messageID) = followTarget,
|
||||
!visibleTurnStartIDs.contains(messageID)
|
||||
{
|
||||
self.followTarget = nil
|
||||
self.hasNewerContentBelow = false
|
||||
}
|
||||
return
|
||||
case let .added(latestUserMessageID):
|
||||
self.lastUserMessageID = latestUserMessageID
|
||||
case let .added(latestTurnStartID):
|
||||
self.lastTurnStartID = latestTurnStartID
|
||||
self.hasNewerContentBelow = false
|
||||
// The anchored-question layout assumes a viewport tall enough to read the turn
|
||||
// below the anchor. With the keyboard up that space is gone and the reply streams
|
||||
@@ -975,8 +979,8 @@ public struct OpenClawChatView: View {
|
||||
self.followTarget = .latest
|
||||
self.moveScrollPosition(to: self.scrollerBottomID)
|
||||
} else {
|
||||
self.followTarget = .user(latestUserMessageID)
|
||||
self.moveScrollPosition(to: latestUserMessageID, anchor: Layout.newTurnAnchor)
|
||||
self.followTarget = .turn(latestTurnStartID)
|
||||
self.moveScrollPosition(to: latestTurnStartID, anchor: Layout.newTurnAnchor)
|
||||
}
|
||||
return
|
||||
case .unchanged:
|
||||
@@ -987,12 +991,12 @@ public struct OpenClawChatView: View {
|
||||
case .latest:
|
||||
self.hasNewerContentBelow = false
|
||||
self.moveScrollPosition(to: self.scrollerBottomID)
|
||||
case let .user(messageID):
|
||||
case let .turn(messageID):
|
||||
// Reader policy stays on this turn after the one-shot scroll binding is released. Reissuing
|
||||
// that target for every streaming delta can loop SwiftUI layout and starve interaction.
|
||||
self.hasNewerContentBelow = chatReaderHasNewerContent(
|
||||
after: messageID,
|
||||
visibleIDs: visibleMessages.map(\.id),
|
||||
visibleIDs: transcriptRows.map(\.id),
|
||||
hasTransientContent: self.hasVisibleTransientContent)
|
||||
case nil:
|
||||
self.hasNewerContentBelow = true
|
||||
@@ -1082,7 +1086,9 @@ extension OpenClawChatView {
|
||||
stopReason: last.stopReason,
|
||||
errorMessage: last.errorMessage,
|
||||
details: last.details,
|
||||
isError: last.isError)
|
||||
isError: last.isError,
|
||||
provenance: last.provenance,
|
||||
historyMarker: last.historyMarker)
|
||||
result[result.count - 1] = merged
|
||||
}
|
||||
|
||||
|
||||
+13
-3
@@ -47,6 +47,8 @@ extension OpenClawChatViewModel {
|
||||
role: message.role,
|
||||
content: sanitizedContent,
|
||||
timestamp: message.timestamp,
|
||||
transcriptMessageID: message.transcriptMessageID,
|
||||
isTruncated: message.isTruncated,
|
||||
idempotencyKey: message.idempotencyKey,
|
||||
toolCallId: message.toolCallId,
|
||||
toolName: message.toolName,
|
||||
@@ -54,7 +56,9 @@ extension OpenClawChatViewModel {
|
||||
stopReason: message.stopReason,
|
||||
errorMessage: message.errorMessage,
|
||||
details: message.details,
|
||||
isError: message.isError)
|
||||
isError: message.isError,
|
||||
provenance: message.provenance,
|
||||
historyMarker: message.historyMarker)
|
||||
}
|
||||
|
||||
static func messageContentFingerprint(for message: OpenClawChatMessage) -> String {
|
||||
@@ -174,7 +178,9 @@ extension OpenClawChatViewModel {
|
||||
stopReason: incoming.stopReason,
|
||||
errorMessage: incoming.errorMessage,
|
||||
details: incoming.details,
|
||||
isError: incoming.isError)
|
||||
isError: incoming.isError,
|
||||
provenance: incoming.provenance ?? existing.provenance,
|
||||
historyMarker: incoming.historyMarker ?? existing.historyMarker)
|
||||
}
|
||||
|
||||
private static func preservingLocalAudioDurations(
|
||||
@@ -480,6 +486,8 @@ extension OpenClawChatViewModel {
|
||||
role: existing.role,
|
||||
content: existing.content,
|
||||
timestamp: existing.timestamp,
|
||||
transcriptMessageID: existing.transcriptMessageID,
|
||||
isTruncated: existing.isTruncated,
|
||||
idempotencyKey: remoteKey,
|
||||
toolCallId: existing.toolCallId,
|
||||
toolName: existing.toolName,
|
||||
@@ -487,7 +495,9 @@ extension OpenClawChatViewModel {
|
||||
stopReason: existing.stopReason,
|
||||
errorMessage: existing.errorMessage,
|
||||
details: existing.details,
|
||||
isError: existing.isError)
|
||||
isError: existing.isError,
|
||||
provenance: existing.provenance,
|
||||
historyMarker: existing.historyMarker)
|
||||
}
|
||||
self.replaceMessages(Self.dedupeMessages(updated))
|
||||
guard let survivingIndex = self.messages.firstIndex(where: { message in
|
||||
|
||||
@@ -596,6 +596,8 @@ extension OpenClawChatViewModel {
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
timestamp: Date().timeIntervalSince1970 * 1000,
|
||||
transcriptMessageID: message.transcriptMessageID,
|
||||
isTruncated: message.isTruncated,
|
||||
idempotencyKey: message.idempotencyKey,
|
||||
toolCallId: message.toolCallId,
|
||||
toolName: message.toolName,
|
||||
@@ -603,7 +605,9 @@ extension OpenClawChatViewModel {
|
||||
stopReason: message.stopReason,
|
||||
errorMessage: message.errorMessage,
|
||||
details: message.details,
|
||||
isError: message.isError)
|
||||
isError: message.isError,
|
||||
provenance: message.provenance,
|
||||
historyMarker: message.historyMarker)
|
||||
}
|
||||
|
||||
private func handleAgentEvent(_ evt: OpenClawAgentEventPayload) {
|
||||
|
||||
+29
-4
@@ -3,9 +3,9 @@ import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
// Tool-result diff metadata rides on `OpenClawChatMessage.details`; every
|
||||
// field-enumerating message rebuild must carry it or inline diffs silently
|
||||
// disappear after cache-warm reconciliation.
|
||||
/// Tool-result diff metadata rides on `OpenClawChatMessage.details`; every
|
||||
/// field-enumerating message rebuild must carry it or inline diffs silently
|
||||
/// disappear after cache-warm reconciliation.
|
||||
@Suite("ChatMessageDetailsPreservation")
|
||||
struct ChatMessageDetailsPreservationTests {
|
||||
private func toolResultMessage(id: UUID = UUID()) -> OpenClawChatMessage {
|
||||
@@ -26,12 +26,31 @@ struct ChatMessageDetailsPreservationTests {
|
||||
details: AnyCodable(["diff": AnyCodable("+1 added\n-1 removed")]))
|
||||
}
|
||||
|
||||
private func systemNoticeMessage(id: UUID = UUID()) -> OpenClawChatMessage {
|
||||
OpenClawChatMessage(
|
||||
id: id,
|
||||
role: "user",
|
||||
content: [
|
||||
OpenClawChatMessageContent(
|
||||
type: "text",
|
||||
text: "[System] gateway restarted",
|
||||
mimeType: nil,
|
||||
fileName: nil,
|
||||
content: nil),
|
||||
],
|
||||
timestamp: 2,
|
||||
provenance: OpenClawChatInputProvenance(
|
||||
kind: "internal_system",
|
||||
sourceTool: "restart-sentinel"))
|
||||
}
|
||||
|
||||
@MainActor @Test func `decode pipeline keeps message details`() throws {
|
||||
let payloadData = try JSONEncoder().encode([self.toolResultMessage()])
|
||||
let payloadData = try JSONEncoder().encode([self.toolResultMessage(), self.systemNoticeMessage()])
|
||||
let anyMessages = try JSONDecoder().decode([AnyCodable].self, from: payloadData)
|
||||
let decoded = OpenClawChatViewModel.decodeMessages(anyMessages)
|
||||
|
||||
#expect(decoded.first?.details != nil)
|
||||
#expect(decoded.last?.provenance?.sourceTool == "restart-sentinel")
|
||||
}
|
||||
|
||||
@MainActor @Test func `canonical adoption keeps incoming details`() {
|
||||
@@ -42,5 +61,11 @@ struct ChatMessageDetailsPreservationTests {
|
||||
|
||||
#expect(adopted.id == existing.id)
|
||||
#expect(adopted.details != nil)
|
||||
|
||||
let incomingNotice = self.systemNoticeMessage()
|
||||
let adoptedNotice = OpenClawChatViewModel.adoptingCanonicalMessage(
|
||||
incomingNotice,
|
||||
over: self.systemNoticeMessage())
|
||||
#expect(adoptedNotice.provenance == incomingNotice.provenance)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
@testable import OpenClawChatUI
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
private func textContent(_ text: String) -> OpenClawChatMessageContent {
|
||||
OpenClawChatMessageContent(type: "text", text: text, mimeType: nil, fileName: nil, content: nil)
|
||||
@@ -18,8 +18,7 @@ private func toolCallContent(name: String) -> OpenClawChatMessageContent {
|
||||
fileName: nil,
|
||||
content: nil,
|
||||
id: "call-1",
|
||||
name: name
|
||||
)
|
||||
name: name)
|
||||
}
|
||||
|
||||
private func thinkingContent(_ thinking: String) -> OpenClawChatMessageContent {
|
||||
@@ -29,8 +28,7 @@ private func thinkingContent(_ thinking: String) -> OpenClawChatMessageContent {
|
||||
thinking: thinking,
|
||||
mimeType: nil,
|
||||
fileName: nil,
|
||||
content: nil
|
||||
)
|
||||
content: nil)
|
||||
}
|
||||
|
||||
@Suite("ChatMessageVisibleText")
|
||||
@@ -43,8 +41,7 @@ struct ChatMessageVisibleTextTests {
|
||||
toolCallContent(name: "exec"),
|
||||
textContent("And a follow-up."),
|
||||
],
|
||||
timestamp: 1
|
||||
)
|
||||
timestamp: 1)
|
||||
|
||||
#expect(ChatMessageVisibleText.visibleText(in: message)
|
||||
== "Here is the answer.\nAnd a follow-up.")
|
||||
@@ -54,8 +51,7 @@ struct ChatMessageVisibleTextTests {
|
||||
let message = OpenClawChatMessage(
|
||||
role: "user",
|
||||
content: [textContent("What is <final>up</final>?")],
|
||||
timestamp: 1
|
||||
)
|
||||
timestamp: 1)
|
||||
|
||||
#expect(ChatMessageVisibleText.visibleText(in: message) == "What is <final>up</final>?")
|
||||
}
|
||||
@@ -64,13 +60,11 @@ struct ChatMessageVisibleTextTests {
|
||||
let assistant = OpenClawChatMessage(
|
||||
role: "assistant",
|
||||
content: [textContent("<think>private reasoning</think>\nVisible **answer**")],
|
||||
timestamp: 1
|
||||
)
|
||||
timestamp: 1)
|
||||
let user = OpenClawChatMessage(
|
||||
role: "user",
|
||||
content: [textContent("Keep <think>this literal tag</think>")],
|
||||
timestamp: 1
|
||||
)
|
||||
timestamp: 1)
|
||||
|
||||
#expect(ChatMessageVisibleText.copyText(in: assistant) == "Visible **answer**")
|
||||
#expect(ChatMessageVisibleText.copyText(in: user) == "Keep <think>this literal tag</think>")
|
||||
@@ -84,8 +78,7 @@ struct ChatMessageVisibleTextTests {
|
||||
textContent("Here is the answer."),
|
||||
toolCallContent(name: "read"),
|
||||
],
|
||||
timestamp: 1
|
||||
)
|
||||
timestamp: 1)
|
||||
|
||||
#expect(ChatMessageVisibleText.displayText(in: message, includeThinking: false)
|
||||
== "Here is the answer.")
|
||||
@@ -104,13 +97,11 @@ struct ChatMessageVisibleTextTests {
|
||||
typedTextContent("tool_result", "tool payload"),
|
||||
typedTextContent(nil, "legacy visible"),
|
||||
],
|
||||
timestamp: 1
|
||||
)
|
||||
timestamp: 1)
|
||||
|
||||
#expect(
|
||||
ChatMessageVisibleText.displayText(in: message, includeThinking: false) ==
|
||||
"visible output\nvisible input\nlegacy visible"
|
||||
)
|
||||
"visible output\nvisible input\nlegacy visible")
|
||||
}
|
||||
|
||||
@Test func `responses text visibility follows the chat role contract`() {
|
||||
@@ -126,25 +117,43 @@ struct ChatMessageVisibleTextTests {
|
||||
for entry in cases {
|
||||
#expect(
|
||||
ChatMessageVisibleText.isVisibleContentType(entry.type, role: entry.role)
|
||||
== entry.expected
|
||||
)
|
||||
== entry.expected)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `history decode retains transcript identity and truncation signals`() throws {
|
||||
@Test func `history decode retains transcript metadata and system row facts`() throws {
|
||||
let metadata = try JSONDecoder().decode(
|
||||
OpenClawChatMessage.self,
|
||||
from: Data(#"{"role":"assistant","content":"short","__openclaw":{"id":"msg-1","truncated":true}}"#.utf8)
|
||||
)
|
||||
from: Data(#"{"role":"assistant","content":"short","__openclaw":{"id":"msg-1","truncated":true}}"#.utf8))
|
||||
let marker = try JSONDecoder().decode(
|
||||
OpenClawChatMessage.self,
|
||||
from: Data(#"{"role":"assistant","content":"short\n...(truncated)...","__openclaw":{"id":"msg-2"}}"#.utf8)
|
||||
)
|
||||
from: Data(#"{"role":"assistant","content":"short\n...(truncated)...","__openclaw":{"id":"msg-2"}}"#.utf8))
|
||||
let notice = try JSONDecoder().decode(
|
||||
OpenClawChatMessage.self,
|
||||
from: Data(
|
||||
#"{"role":"user","content":"[System] resumed","provenance":{"kind":"internal_system","originSessionId":"origin-1","sourceSessionKey":"agent:main","sourceChannel":"system","sourceTool":"restart-sentinel"}}"#
|
||||
.utf8))
|
||||
let historyMarker = try JSONDecoder().decode(
|
||||
OpenClawChatMessage.self,
|
||||
from: Data(
|
||||
#"{"role":"system","content":[],"__openclaw":{"kind":"compaction","id":"compact-1","tokensBefore":22000,"tokensAfter":9000}}"#
|
||||
.utf8))
|
||||
|
||||
#expect(metadata.transcriptMessageID == "msg-1")
|
||||
#expect(metadata.isTruncated)
|
||||
#expect(marker.transcriptMessageID == "msg-2")
|
||||
#expect(marker.isTruncated)
|
||||
#expect(notice.provenance == OpenClawChatInputProvenance(
|
||||
kind: "internal_system",
|
||||
originSessionId: "origin-1",
|
||||
sourceSessionKey: "agent:main",
|
||||
sourceChannel: "system",
|
||||
sourceTool: "restart-sentinel"))
|
||||
#expect(historyMarker.historyMarker == OpenClawChatHistoryMarker(
|
||||
kind: "compaction",
|
||||
id: "compact-1",
|
||||
tokensBefore: 22000,
|
||||
tokensAfter: 9000))
|
||||
}
|
||||
|
||||
@Test func `transcript metadata survives message coding round trip`() throws {
|
||||
@@ -153,16 +162,31 @@ struct ChatMessageVisibleTextTests {
|
||||
content: [textContent("short\n...(truncated)...")],
|
||||
timestamp: 1,
|
||||
transcriptMessageID: "msg-round-trip",
|
||||
isTruncated: true
|
||||
)
|
||||
isTruncated: true)
|
||||
|
||||
let decoded = try JSONDecoder().decode(
|
||||
OpenClawChatMessage.self,
|
||||
from: JSONEncoder().encode(original)
|
||||
)
|
||||
from: JSONEncoder().encode(original))
|
||||
let systemRow = OpenClawChatMessage(
|
||||
role: "system",
|
||||
content: [],
|
||||
timestamp: 2,
|
||||
provenance: OpenClawChatInputProvenance(
|
||||
kind: "internal_system",
|
||||
sourceTool: "restart-sentinel"),
|
||||
historyMarker: OpenClawChatHistoryMarker(
|
||||
kind: "compaction",
|
||||
id: "compact-round-trip",
|
||||
tokensBefore: 10000,
|
||||
tokensAfter: 4000))
|
||||
let decodedSystemRow = try JSONDecoder().decode(
|
||||
OpenClawChatMessage.self,
|
||||
from: JSONEncoder().encode(systemRow))
|
||||
|
||||
#expect(decoded.transcriptMessageID == "msg-round-trip")
|
||||
#expect(decoded.isTruncated)
|
||||
#expect(decodedSystemRow.provenance == systemRow.provenance)
|
||||
#expect(decodedSystemRow.historyMarker == systemRow.historyMarker)
|
||||
}
|
||||
|
||||
@Test func `legacy trace mapping sets both independent display options`() {
|
||||
@@ -175,23 +199,19 @@ struct ChatMessageVisibleTextTests {
|
||||
let toolOnly = OpenClawChatMessage(
|
||||
role: "assistant",
|
||||
content: [toolCallContent(name: "exec")],
|
||||
timestamp: 1
|
||||
)
|
||||
timestamp: 1)
|
||||
let blank = OpenClawChatMessage(
|
||||
role: "assistant",
|
||||
content: [textContent(" ")],
|
||||
timestamp: 1
|
||||
)
|
||||
timestamp: 1)
|
||||
let spoken = OpenClawChatMessage(
|
||||
role: "assistant",
|
||||
content: [textContent("Say this")],
|
||||
timestamp: 1
|
||||
)
|
||||
timestamp: 1)
|
||||
let thinkingOnly = OpenClawChatMessage(
|
||||
role: "assistant",
|
||||
content: [textContent("<think>Do not speak this</think>")],
|
||||
timestamp: 1
|
||||
)
|
||||
timestamp: 1)
|
||||
|
||||
#expect(!ChatMessageVisibleText.hasVisibleText(in: toolOnly))
|
||||
#expect(!ChatMessageVisibleText.hasVisibleText(in: blank))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
@@ -309,7 +310,7 @@ extension OpenClawChatViewModel {
|
||||
|
||||
// MARK: - Markdown shapes fixture
|
||||
|
||||
// Extended-delimiter literal keeps the fenced Swift interpolation inert.
|
||||
/// Extended-delimiter literal keeps the fenced Swift interpolation inert.
|
||||
private let markdownShapesFixture = #"""
|
||||
# Release Notes
|
||||
|
||||
@@ -344,6 +345,37 @@ Closing paragraph with unicode — dashes, émojis 🦀🚀, and a trailing line
|
||||
/// `session.message` rows, duplicate delivery, out-of-order arrival, and reconnect
|
||||
/// convergence. Tracking: #100196.
|
||||
struct ChatStreamReplayTests {
|
||||
@Test func `live session message marker produces a visible transcript row`() async throws {
|
||||
let harness = try await StreamReplayHarness.bootstrapped()
|
||||
let frame = EventFrame(
|
||||
type: "event",
|
||||
event: "session.message",
|
||||
payload: AnyCodable([
|
||||
"sessionKey": "main",
|
||||
"messageId": "live-reset",
|
||||
"message": [
|
||||
"role": "system",
|
||||
"content": [],
|
||||
"timestamp": 1,
|
||||
"__openclaw": ["kind": "reset", "id": "live-reset"],
|
||||
],
|
||||
]))
|
||||
let event = try #require(OpenClawChatGatewayPayloadCodec.event(from: frame))
|
||||
|
||||
harness.transport.emit(event)
|
||||
try await harness.converge("live reset marker appended") { vm in
|
||||
vm.messages.contains { $0.historyMarker?.kind == "reset" }
|
||||
}
|
||||
|
||||
let rows = await MainActor.run { ChatTranscriptRow.build(from: harness.vm.messages) }
|
||||
guard let last = rows.last, case let .historyDivider(divider) = last else {
|
||||
Issue.record("Expected the live reset marker to produce a divider")
|
||||
return
|
||||
}
|
||||
#expect(divider.label == "Session reset")
|
||||
#expect(divider.description == "The earlier conversation was cleared.")
|
||||
}
|
||||
|
||||
@Test func `clean streaming run converges losslessly to durable rows`() async throws {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let finalText = "Hello, world!"
|
||||
|
||||
@@ -495,7 +495,15 @@ final class ChatTranscriptCacheStoreTests: ClientDatabaseTestSuite, @unchecked S
|
||||
details: AnyCodable(["diff": AnyCodable(oversizedDiff), "ignored": AnyCodable("drop")])),
|
||||
],
|
||||
timestamp: 1,
|
||||
details: AnyCodable(["diff": AnyCodable(oversizedDiff), "ignored": AnyCodable("drop")]))
|
||||
details: AnyCodable(["diff": AnyCodable(oversizedDiff), "ignored": AnyCodable("drop")]),
|
||||
provenance: OpenClawChatInputProvenance(
|
||||
kind: "internal_system",
|
||||
sourceTool: "restart-sentinel"),
|
||||
historyMarker: OpenClawChatHistoryMarker(
|
||||
kind: "compaction",
|
||||
id: "compact-cache",
|
||||
tokensBefore: 12000,
|
||||
tokensAfter: 7000))
|
||||
|
||||
let cached = try #require(OpenClawChatSQLiteTranscriptCache.cacheableMessages([message]).first)
|
||||
#expect(cached.content[0].content == nil)
|
||||
@@ -503,6 +511,8 @@ final class ChatTranscriptCacheStoreTests: ClientDatabaseTestSuite, @unchecked S
|
||||
#expect(Set(cached.content[0].arguments?.dictionaryValue?.keys.map(\.self) ?? []) == ["input"])
|
||||
#expect(cached.content[0].arguments?.dictionaryValue?["input"]?.stringValue?.utf16.count == 64000)
|
||||
#expect(Set(cached.details?.dictionaryValue?.keys.map(\.self) ?? []) == ["diff"])
|
||||
#expect(cached.provenance == message.provenance)
|
||||
#expect(cached.historyMarker == message.historyMarker)
|
||||
}
|
||||
|
||||
@Test func `gateway removal deletes only that gateways cache and state`() async throws {
|
||||
|
||||
@@ -1,8 +1,59 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
@Suite("ChatTranscriptExporter")
|
||||
struct ChatTranscriptExporterTests {
|
||||
@Test func `exports system transcript rows without leaking internal prompts`() throws {
|
||||
let wire = Data(#"""
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "[System] Resume the interrupted turn with private context."}],
|
||||
"timestamp": 0,
|
||||
"provenance": {"kind": "internal_system", "sourceTool": "main_session_restart_recovery"}
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "[System] Gateway restarted after an update."}],
|
||||
"timestamp": 500,
|
||||
"provenance": {"kind": "internal_system", "sourceTool": "restart-sentinel"}
|
||||
},
|
||||
{
|
||||
"role": "system",
|
||||
"content": [],
|
||||
"timestamp": 1000,
|
||||
"__openclaw": {"kind": "compaction", "id": "compact-1", "tokensBefore": 25000, "tokensAfter": 12500}
|
||||
},
|
||||
{
|
||||
"role": "system",
|
||||
"content": [],
|
||||
"timestamp": 2000,
|
||||
"__openclaw": {"kind": "reset", "id": "reset-1"}
|
||||
},
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "Unknown marker body"}],
|
||||
"timestamp": 3000,
|
||||
"__openclaw": {"kind": "future-marker", "id": "future-1"}
|
||||
}
|
||||
]
|
||||
"""#.utf8)
|
||||
let messages = try JSONDecoder().decode([OpenClawChatMessage].self, from: wire)
|
||||
|
||||
let markdown = ChatTranscriptExporter.markdown(
|
||||
sessionTitle: "System events",
|
||||
sessionKey: "agent:main",
|
||||
messages: messages)
|
||||
|
||||
#expect(!markdown.contains("private context"))
|
||||
#expect(markdown.contains("System · restart recovery"))
|
||||
#expect(markdown.contains("[System · gateway restarted] Gateway restarted after an update."))
|
||||
#expect(markdown.contains("[Compacted history · saved 12.5k tokens]"))
|
||||
#expect(markdown.contains("[Session reset — The earlier conversation was cleared.]"))
|
||||
#expect(!markdown.contains("Unknown marker body"))
|
||||
}
|
||||
|
||||
@Test func `formats visible messages and attachments`() {
|
||||
let messages = [
|
||||
self.message(role: "system", text: "Hidden setup", timestamp: 0),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
@Suite("ChatTranscriptRow")
|
||||
struct ChatTranscriptRowTests {
|
||||
private enum Input: Sendable {
|
||||
case notice(sourceTool: String?, text: String)
|
||||
case marker(kind: String, tokensBefore: Double? = nil, tokensAfter: Double? = nil)
|
||||
}
|
||||
|
||||
private enum Expected: Sendable {
|
||||
case notice(label: String, body: String)
|
||||
case divider(label: String, metric: String?, description: String?)
|
||||
case hidden
|
||||
}
|
||||
|
||||
private struct Case: Sendable {
|
||||
let name: String
|
||||
let input: Input
|
||||
let expected: Expected
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
Case(
|
||||
name: "restart recovery hides producer prompt",
|
||||
input: .notice(
|
||||
sourceTool: "main_session_restart_recovery",
|
||||
text: "[System] private recovery prompt"),
|
||||
expected: .notice(
|
||||
label: "System · restart recovery",
|
||||
body: "Turn interrupted by a gateway restart — asked the agent to resume and finish the response.")),
|
||||
Case(
|
||||
name: "restart sentinel keeps producer text",
|
||||
input: .notice(
|
||||
sourceTool: "restart-sentinel",
|
||||
text: "[System] Gateway restarted after an update."),
|
||||
expected: .notice(
|
||||
label: "System · gateway restarted",
|
||||
body: "Gateway restarted after an update.")),
|
||||
Case(
|
||||
name: "other source tool is generic without fuzzy matching",
|
||||
input: .notice(
|
||||
sourceTool: "Restart-Sentinel",
|
||||
text: "[System] Doctor repaired the gateway."),
|
||||
expected: .notice(
|
||||
label: "System",
|
||||
body: "Doctor repaired the gateway.")),
|
||||
Case(
|
||||
name: "compaction reports finite token savings",
|
||||
input: .marker(kind: "compaction", tokensBefore: 22750, tokensAfter: 9200),
|
||||
expected: .divider(
|
||||
label: "Compacted history",
|
||||
metric: "saved 13.6k tokens",
|
||||
description: nil)),
|
||||
Case(
|
||||
name: "reset explains cleared history",
|
||||
input: .marker(kind: "reset"),
|
||||
expected: .divider(
|
||||
label: "Session reset",
|
||||
metric: nil,
|
||||
description: "The earlier conversation was cleared.")),
|
||||
Case(
|
||||
name: "unknown marker is hidden without fuzzy matching",
|
||||
input: .marker(kind: "Compaction", tokensBefore: 10000, tokensAfter: 1000),
|
||||
expected: .hidden),
|
||||
])
|
||||
private func `classifies control UI system row contracts`(testCase: Case) {
|
||||
let message = switch testCase.input {
|
||||
case let .notice(sourceTool, text):
|
||||
OpenClawChatMessage(
|
||||
role: "user",
|
||||
content: [OpenClawChatMessageContent(
|
||||
type: "text",
|
||||
text: text,
|
||||
mimeType: nil,
|
||||
fileName: nil,
|
||||
content: nil)],
|
||||
timestamp: 1,
|
||||
provenance: OpenClawChatInputProvenance(
|
||||
kind: "internal_system",
|
||||
sourceTool: sourceTool))
|
||||
case let .marker(kind, tokensBefore, tokensAfter):
|
||||
OpenClawChatMessage(
|
||||
role: "system",
|
||||
content: [],
|
||||
timestamp: 1,
|
||||
historyMarker: OpenClawChatHistoryMarker(
|
||||
kind: kind,
|
||||
id: "marker-1",
|
||||
tokensBefore: tokensBefore,
|
||||
tokensAfter: tokensAfter))
|
||||
}
|
||||
|
||||
let row = ChatTranscriptRow(message)
|
||||
switch (testCase.expected, row) {
|
||||
case (.hidden, nil):
|
||||
break
|
||||
case let (.notice(expectedLabel, expectedBody), .systemNotice(notice)):
|
||||
#expect(notice.label == expectedLabel)
|
||||
#expect(notice.body == expectedBody)
|
||||
case let (.divider(expectedLabel, expectedMetric, expectedDescription), .historyDivider(divider)):
|
||||
#expect(divider.label == expectedLabel)
|
||||
#expect(divider.metric == expectedMetric)
|
||||
#expect(divider.description == expectedDescription)
|
||||
default:
|
||||
Issue.record("Unexpected row classification for \(testCase.name)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(nil, nil),
|
||||
(10000, nil),
|
||||
(10000, 10000),
|
||||
(10000, 12000),
|
||||
(Double.infinity, 1000),
|
||||
])
|
||||
func `compaction omits invalid token metrics`(tokensBefore: Double?, tokensAfter: Double?) throws {
|
||||
let message = OpenClawChatMessage(
|
||||
role: "system",
|
||||
content: [],
|
||||
timestamp: 1,
|
||||
historyMarker: OpenClawChatHistoryMarker(
|
||||
kind: "compaction",
|
||||
tokensBefore: tokensBefore,
|
||||
tokensAfter: tokensAfter))
|
||||
|
||||
guard case let .historyDivider(divider) = try #require(ChatTranscriptRow(message)) else {
|
||||
Issue.record("Expected a compaction divider")
|
||||
return
|
||||
}
|
||||
#expect(divider.metric == nil)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(0, "0"),
|
||||
(999, "999"),
|
||||
(1000, "1k"),
|
||||
(214_500, "214.5k"),
|
||||
(999_950, "1M"),
|
||||
(1_050_000, "1.1M"),
|
||||
])
|
||||
func `compact token counts mirror the control UI`(tokens: Double, expected: String) {
|
||||
#expect(ChatCompactTokenCountFormatter.string(tokens) == expected)
|
||||
}
|
||||
}
|
||||
@@ -11486,6 +11486,50 @@ struct ChatViewModelTests {
|
||||
#expect(sanitized == "Hello?")
|
||||
}
|
||||
|
||||
@Test func `history system facts survive sanitation and produce visible rows`() async throws {
|
||||
let history = historyPayloadWithoutRunState(
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "user",
|
||||
"content": [["type": "text", "text": "[System] Gateway restarted cleanly."]],
|
||||
"timestamp": 1,
|
||||
"provenance": [
|
||||
"kind": "internal_system",
|
||||
"sourceTool": "restart-sentinel",
|
||||
],
|
||||
]),
|
||||
AnyCodable([
|
||||
"role": "system",
|
||||
"content": [],
|
||||
"timestamp": 2,
|
||||
"__openclaw": [
|
||||
"kind": "compaction",
|
||||
"id": "compact-history",
|
||||
"tokensBefore": 20000,
|
||||
"tokensAfter": 8000,
|
||||
],
|
||||
]),
|
||||
])
|
||||
let transport = TestChatTransport(historyResponses: [history])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("system history loaded") { await MainActor.run { vm.messages.count == 2 } }
|
||||
|
||||
let rows = await MainActor.run { ChatTranscriptRow.build(from: vm.messages) }
|
||||
#expect(rows.count == 2)
|
||||
guard let first = rows.first, case let .systemNotice(notice) = first else {
|
||||
Issue.record("Expected a restart notice")
|
||||
return
|
||||
}
|
||||
#expect(notice.body == "Gateway restarted cleanly.")
|
||||
guard let last = rows.last, case let .historyDivider(divider) = last else {
|
||||
Issue.record("Expected a compaction divider")
|
||||
return
|
||||
}
|
||||
#expect(divider.metric == "saved 12k tokens")
|
||||
}
|
||||
|
||||
@Test func `abort requests do not clear pending until aborted event`() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let history = historyPayload(sessionId: sessionId)
|
||||
|
||||
Reference in New Issue
Block a user