diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b9e896409c..de5ba4d29c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index d24f92bd42c..975908dfaa2 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -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] { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift index bf77fe037de..afe589c32a0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift @@ -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) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index 4faeac05870..6008229fd0a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -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 { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index ac8fbb2d2ab..912491ca909 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -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) } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index 278f0a76174..b7e5c6d1b85 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -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)])