Render provider errors in chat history (#65689)

Merged via squash.

Prepared head SHA: a777c7506e
Co-authored-by: javierdici <131621115+javierdici@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
javierdici
2026-05-14 16:48:41 -05:00
committed by GitHub
parent 99a6b1c5a8
commit f6c00456dc
6 changed files with 198 additions and 12 deletions

View File

@@ -607,6 +607,7 @@ Docs: https://docs.openclaw.ai
- Exec approvals: omit generated command highlights for non-POSIX Windows and shell-wrapper approval commands until those command languages have native highlighting support. (#80566) Thanks @jesse-merhi.
- Telegram: keep verbose tool progress and result drafts separate from the final assistant answer so tool output no longer blends into the final Telegram message. (#80294) Thanks @jalehman.
- Plugin SDK/Windows: enable the native require fast path for root `openclaw/plugin-sdk` dist aliases instead of forcing Jiti transforms. (#80878) Thanks @medns.
- macOS/Chat: render persisted assistant provider failures from `errorMessage` in refreshed chat history while keeping stale non-error provider details hidden. (#65689) Thanks @javierdici.
## 2026.5.9

View File

@@ -253,7 +253,11 @@ private struct ChatMessageBody: View {
guard kind == "text" || kind.isEmpty else { return nil }
return content.text
}
return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
return OpenClawChatMessage.displayText(
contentText: parts.joined(separator: "\n"),
role: self.message.role,
stopReason: self.message.stopReason,
errorMessage: self.message.errorMessage)
}
private var inlineAttachments: [OpenClawChatMessageContent] {

View File

@@ -144,6 +144,7 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
public let toolName: String?
public let usage: OpenClawChatUsage?
public let stopReason: String?
public let errorMessage: String?
enum CodingKeys: String, CodingKey {
case role
@@ -155,6 +156,7 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
case tool_name
case usage
case stopReason
case errorMessage
}
public init(
@@ -165,7 +167,8 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
toolCallId: String? = nil,
toolName: String? = nil,
usage: OpenClawChatUsage? = nil,
stopReason: String? = nil)
stopReason: String? = nil,
errorMessage: String? = nil)
{
self.id = id
self.role = role
@@ -175,20 +178,30 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
self.toolName = toolName
self.usage = usage
self.stopReason = stopReason
self.errorMessage = errorMessage
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.role = try container.decode(String.self, forKey: .role)
self.timestamp = try container.decodeIfPresent(Double.self, forKey: .timestamp)
self.toolCallId =
let decodedRole = try container.decode(String.self, forKey: .role)
let decodedTimestamp = try container.decodeIfPresent(Double.self, forKey: .timestamp)
let decodedToolCallId =
try container.decodeIfPresent(String.self, forKey: .toolCallId) ??
container.decodeIfPresent(String.self, forKey: .tool_call_id)
self.toolName =
let decodedToolName =
try container.decodeIfPresent(String.self, forKey: .toolName) ??
container.decodeIfPresent(String.self, forKey: .tool_name)
self.usage = try container.decodeIfPresent(OpenClawChatUsage.self, forKey: .usage)
self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
let decodedUsage = try container.decodeIfPresent(OpenClawChatUsage.self, forKey: .usage)
let decodedStopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
let decodedErrorMessage = try container.decodeIfPresent(String.self, forKey: .errorMessage)
self.role = decodedRole
self.timestamp = decodedTimestamp
self.toolCallId = decodedToolCallId
self.toolName = decodedToolName
self.usage = decodedUsage
self.stopReason = decodedStopReason
self.errorMessage = decodedErrorMessage
if let decoded = try? container.decode([OpenClawChatMessageContent].self, forKey: .content) {
self.content = decoded
@@ -216,6 +229,41 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
self.content = []
}
static func displayText(
contentText: String,
role: String,
stopReason: String?,
errorMessage: String?) -> String
{
let text = contentText.trimmingCharacters(in: .whitespacesAndNewlines)
guard let errorText = Self.errorDisplayText(
role: role,
stopReason: stopReason,
errorMessage: errorMessage)
else {
return text
}
if text.isEmpty || text == Self.streamErrorFallbackText {
return errorText
}
return text
}
static func errorDisplayText(role: String, stopReason: String?, errorMessage: String?) -> String? {
let normalizedRole = role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let normalizedStopReason = stopReason?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard normalizedRole == "assistant",
normalizedStopReason == "error",
let text = errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty
else {
return nil
}
return text
}
private static let streamErrorFallbackText = "[assistant turn failed before producing content]"
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.role, forKey: .role)
@@ -224,6 +272,7 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
try container.encodeIfPresent(self.toolName, forKey: .toolName)
try container.encodeIfPresent(self.usage, forKey: .usage)
try container.encodeIfPresent(self.stopReason, forKey: .stopReason)
try container.encodeIfPresent(self.errorMessage, forKey: .errorMessage)
try container.encode(self.content, forKey: .content)
}
}

View File

@@ -389,7 +389,8 @@ public struct OpenClawChatView: View {
toolCallId: last.toolCallId,
toolName: last.toolName,
usage: last.usage,
stopReason: last.stopReason)
stopReason: last.stopReason,
errorMessage: last.errorMessage)
result[result.count - 1] = merged
}
@@ -433,7 +434,11 @@ public struct OpenClawChatView: View {
guard kind == "text" || kind.isEmpty else { return nil }
return content.text
}
return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
return OpenClawChatMessage.displayText(
contentText: parts.joined(separator: "\n"),
role: message.role,
stopReason: message.stopReason,
errorMessage: message.errorMessage)
}
private func hasInlineAttachments(in message: OpenClawChatMessage) -> Bool {

View File

@@ -305,7 +305,8 @@ public final class OpenClawChatViewModel {
toolCallId: message.toolCallId,
toolName: message.toolName,
usage: message.usage,
stopReason: message.stopReason)
stopReason: message.stopReason,
errorMessage: message.errorMessage)
}
private static func messageContentFingerprint(for message: OpenClawChatMessage) -> String {
@@ -384,7 +385,8 @@ public final class OpenClawChatViewModel {
toolCallId: message.toolCallId,
toolName: message.toolName,
usage: message.usage,
stopReason: message.stopReason)
stopReason: message.stopReason,
errorMessage: message.errorMessage)
}
}

View File

@@ -11,6 +11,16 @@ private func chatTextMessage(role: String, text: String, timestamp: Double) -> A
])
}
private func chatErrorMessage(role: String, errorMessage: String, timestamp: Double) -> AnyCodable {
AnyCodable([
"role": role,
"content": [],
"timestamp": timestamp,
"stopReason": "error",
"errorMessage": errorMessage,
])
}
private func historyPayload(
sessionKey: String = "main",
sessionId: String? = "sess-main",
@@ -454,6 +464,76 @@ extension TestChatTransportState {
}
@Suite struct ChatViewModelTests {
@Test func displaysErrorMessageFallbackOnlyForAssistantErrorTurns() throws {
func decodeMessage(role: String, stopReason: String, contentText: String? = nil) throws -> OpenClawChatMessage {
let contentJSON = contentText.map { #"[{"type":"text","text":"\#($0)"}]"# } ?? "[]"
let data = """
{
"role": "\(role)",
"content": \(contentJSON),
"timestamp": 1,
"stopReason": "\(stopReason)",
"errorMessage": "stale provider failure"
}
""".data(using: .utf8)!
return try JSONDecoder().decode(OpenClawChatMessage.self, from: data)
}
let assistantError = try decodeMessage(role: "assistant", stopReason: "error")
#expect(assistantError.content.isEmpty)
#expect(
OpenClawChatMessage.errorDisplayText(
role: assistantError.role,
stopReason: assistantError.stopReason,
errorMessage: assistantError.errorMessage) == "stale provider failure")
#expect(
OpenClawChatMessage.displayText(
contentText: "",
role: assistantError.role,
stopReason: assistantError.stopReason,
errorMessage: assistantError.errorMessage) == "stale provider failure")
let sentinelAssistant = try decodeMessage(
role: "assistant",
stopReason: "error",
contentText: "[assistant turn failed before producing content]")
#expect(
OpenClawChatMessage.displayText(
contentText: sentinelAssistant.content.compactMap(\.text).joined(separator: "\n"),
role: sentinelAssistant.role,
stopReason: sentinelAssistant.stopReason,
errorMessage: sentinelAssistant.errorMessage) == "stale provider failure")
let partialAssistant = try decodeMessage(
role: "assistant",
stopReason: "error",
contentText: "partial answer")
#expect(
OpenClawChatMessage.displayText(
contentText: partialAssistant.content.compactMap(\.text).joined(separator: "\n"),
role: partialAssistant.role,
stopReason: partialAssistant.stopReason,
errorMessage: partialAssistant.errorMessage) == "partial answer")
let stoppedAssistant = try decodeMessage(role: "assistant", stopReason: "stop")
#expect(stoppedAssistant.errorMessage == "stale provider failure")
#expect(stoppedAssistant.content.isEmpty)
#expect(
OpenClawChatMessage.errorDisplayText(
role: stoppedAssistant.role,
stopReason: stoppedAssistant.stopReason,
errorMessage: stoppedAssistant.errorMessage) == nil)
let toolUseAssistant = try decodeMessage(role: "assistant", stopReason: "toolUse")
#expect(toolUseAssistant.errorMessage == "stale provider failure")
#expect(toolUseAssistant.content.isEmpty)
#expect(
OpenClawChatMessage.errorDisplayText(
role: toolUseAssistant.role,
stopReason: toolUseAssistant.stopReason,
errorMessage: toolUseAssistant.errorMessage) == nil)
}
@Test func streamsAssistantAndClearsOnFinal() async throws {
let sessionId = "sess-main"
let history1 = historyPayload(sessionId: sessionId)
@@ -665,6 +745,51 @@ extension TestChatTransportState {
}
}
@Test func surfacesAssistantErrorMessageAfterOwnRunRefresh() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history1 = historyPayload()
let history2 = historyPayload(
messages: [
chatErrorMessage(
role: "assistant",
errorMessage: "You have hit your ChatGPT usage limit (plus plan). Try again in ~28 min.",
timestamp: now),
])
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
try await loadAndWaitBootstrap(vm: vm)
await sendUserMessage(vm)
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
let runId = try #require(await transport.lastSentRunId())
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "main",
state: "error",
message: nil,
errorMessage: "You have hit your ChatGPT usage limit (plus plan). Try again in ~28 min.")))
try await waitUntil("pending run clears after error") {
await MainActor.run { vm.pendingRunCount == 0 }
}
try await waitUntil("history refresh shows assistant error message") {
await MainActor.run {
vm.messages.contains(where: { message in
message.role == "assistant" &&
OpenClawChatMessage.displayText(
contentText: message.content.compactMap(\.text).joined(separator: "\n"),
role: message.role,
stopReason: message.stopReason,
errorMessage: message.errorMessage)
.contains("You have hit your ChatGPT usage limit")
})
}
}
}
@Test func acceptsCanonicalSessionKeyEventsForExternalRuns() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history1 = historyPayload(messages: [chatTextMessage(role: "user", text: "first", timestamp: now)])