mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 16:44:45 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)])
|
||||
|
||||
Reference in New Issue
Block a user