mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
iOS Chat: clean UI noise and format tool outputs (#22122)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 34dd87b0c0
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Changes
|
||||
|
||||
- Channels/CLI: add per-account/channel `defaultTo` outbound routing fallback so `openclaw agent --deliver` can send without explicit `--reply-to` when a default target is configured. (#16985) Thanks @KirillShchetinin.
|
||||
- iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky.
|
||||
- iOS/Tests: cover IPv4-mapped IPv6 loopback in manual TLS policy tests for connect validation paths. (#22045) Thanks @mbelinky.
|
||||
- iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky.
|
||||
- Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant.
|
||||
|
||||
@@ -180,10 +180,12 @@ struct OpenClawChatComposer: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
self.editorOverlay
|
||||
|
||||
Rectangle()
|
||||
.fill(OpenClawChatTheme.divider)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, 2)
|
||||
if !self.isComposerCompacted {
|
||||
Rectangle()
|
||||
.fill(OpenClawChatTheme.divider)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
if self.showsConnectionPill {
|
||||
@@ -308,7 +310,7 @@ struct OpenClawChatComposer: View {
|
||||
}
|
||||
|
||||
private var showsToolbar: Bool {
|
||||
self.style == .standard
|
||||
self.style == .standard && !self.isComposerCompacted
|
||||
}
|
||||
|
||||
private var showsAttachments: Bool {
|
||||
@@ -316,15 +318,15 @@ struct OpenClawChatComposer: View {
|
||||
}
|
||||
|
||||
private var showsConnectionPill: Bool {
|
||||
self.style == .standard
|
||||
self.style == .standard && !self.isComposerCompacted
|
||||
}
|
||||
|
||||
private var composerPadding: CGFloat {
|
||||
self.style == .onboarding ? 5 : 6
|
||||
self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6)
|
||||
}
|
||||
|
||||
private var editorPadding: CGFloat {
|
||||
self.style == .onboarding ? 5 : 6
|
||||
self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6)
|
||||
}
|
||||
|
||||
private var textMinHeight: CGFloat {
|
||||
@@ -335,6 +337,14 @@ struct OpenClawChatComposer: View {
|
||||
self.style == .onboarding ? 52 : 64
|
||||
}
|
||||
|
||||
private var isComposerCompacted: Bool {
|
||||
#if os(macOS)
|
||||
false
|
||||
#else
|
||||
self.style == .standard && self.isFocused
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func pickFilesMac() {
|
||||
let panel = NSOpenPanel()
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
enum ChatMarkdownPreprocessor {
|
||||
private static let inboundContextHeaders = [
|
||||
"Conversation info (untrusted metadata):",
|
||||
"Sender (untrusted metadata):",
|
||||
"Thread starter (untrusted, for context):",
|
||||
"Replied message (untrusted, for context):",
|
||||
"Forwarded message context (untrusted metadata):",
|
||||
"Chat history since last reply (untrusted, for context):",
|
||||
]
|
||||
|
||||
struct InlineImage: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
@@ -13,17 +22,21 @@ enum ChatMarkdownPreprocessor {
|
||||
}
|
||||
|
||||
static func preprocess(markdown raw: String) -> Result {
|
||||
let withoutContextBlocks = self.stripInboundContextBlocks(raw)
|
||||
let withoutTimestamps = self.stripPrefixedTimestamps(withoutContextBlocks)
|
||||
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
|
||||
guard let re = try? NSRegularExpression(pattern: pattern) else {
|
||||
return Result(cleaned: raw, images: [])
|
||||
return Result(cleaned: self.normalize(withoutTimestamps), images: [])
|
||||
}
|
||||
|
||||
let ns = raw as NSString
|
||||
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
|
||||
if matches.isEmpty { return Result(cleaned: raw, images: []) }
|
||||
let ns = withoutTimestamps as NSString
|
||||
let matches = re.matches(
|
||||
in: withoutTimestamps,
|
||||
range: NSRange(location: 0, length: ns.length))
|
||||
if matches.isEmpty { return Result(cleaned: self.normalize(withoutTimestamps), images: []) }
|
||||
|
||||
var images: [InlineImage] = []
|
||||
var cleaned = raw
|
||||
var cleaned = withoutTimestamps
|
||||
|
||||
for match in matches.reversed() {
|
||||
guard match.numberOfRanges >= 3 else { continue }
|
||||
@@ -43,9 +56,32 @@ enum ChatMarkdownPreprocessor {
|
||||
cleaned.replaceSubrange(start..<end, with: "")
|
||||
}
|
||||
|
||||
let normalized = cleaned
|
||||
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Result(cleaned: normalized, images: images.reversed())
|
||||
return Result(cleaned: self.normalize(cleaned), images: images.reversed())
|
||||
}
|
||||
|
||||
private static func stripInboundContextBlocks(_ raw: String) -> String {
|
||||
var output = raw
|
||||
for header in self.inboundContextHeaders {
|
||||
let escaped = NSRegularExpression.escapedPattern(for: header)
|
||||
let pattern = "(?ms)^" + escaped + "\\n```json\\n.*?\\n```\\n?"
|
||||
output = output.replacingOccurrences(
|
||||
of: pattern,
|
||||
with: "",
|
||||
options: .regularExpression)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func stripPrefixedTimestamps(_ raw: String) -> String {
|
||||
let pattern = #"(?m)^\[[A-Za-z]{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+(?:GMT|UTC)[+-]?\d{0,2}\]\s*"#
|
||||
return raw.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
|
||||
}
|
||||
|
||||
private static func normalize(_ raw: String) -> String {
|
||||
var output = raw
|
||||
output = output.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
||||
output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
||||
return output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,8 @@ private struct ChatMessageBody: View {
|
||||
ToolResultCard(
|
||||
title: self.toolResultTitle,
|
||||
text: text,
|
||||
isUser: self.isUser)
|
||||
isUser: self.isUser,
|
||||
toolName: self.message.toolName)
|
||||
}
|
||||
} else if self.isUser {
|
||||
ChatMarkdownRenderer(
|
||||
@@ -207,7 +208,8 @@ private struct ChatMessageBody: View {
|
||||
ToolResultCard(
|
||||
title: "\(display.emoji) \(display.title)",
|
||||
text: toolResult.text ?? "",
|
||||
isUser: self.isUser)
|
||||
isUser: self.isUser,
|
||||
toolName: toolResult.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -402,47 +404,54 @@ private struct ToolResultCard: View {
|
||||
let title: String
|
||||
let text: String
|
||||
let isUser: Bool
|
||||
let toolName: String?
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Text(self.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Text(self.displayText)
|
||||
.font(.footnote.monospaced())
|
||||
.foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText)
|
||||
.lineLimit(self.expanded ? nil : Self.previewLineLimit)
|
||||
|
||||
if self.shouldShowToggle {
|
||||
Button(self.expanded ? "Show less" : "Show full output") {
|
||||
self.expanded.toggle()
|
||||
if !self.displayContent.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Text(self.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Text(self.displayText)
|
||||
.font(.footnote.monospaced())
|
||||
.foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText)
|
||||
.lineLimit(self.expanded ? nil : Self.previewLineLimit)
|
||||
|
||||
if self.shouldShowToggle {
|
||||
Button(self.expanded ? "Show less" : "Show full output") {
|
||||
self.expanded.toggle()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(OpenClawChatTheme.subtleCard)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)))
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(OpenClawChatTheme.subtleCard)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)))
|
||||
}
|
||||
|
||||
private static let previewLineLimit = 8
|
||||
|
||||
private var displayContent: String {
|
||||
ToolResultTextFormatter.format(text: self.text, toolName: self.toolName)
|
||||
}
|
||||
|
||||
private var lines: [Substring] {
|
||||
self.text.components(separatedBy: .newlines).map { Substring($0) }
|
||||
self.displayContent.components(separatedBy: .newlines).map { Substring($0) }
|
||||
}
|
||||
|
||||
private var displayText: String {
|
||||
guard !self.expanded, self.lines.count > Self.previewLineLimit else { return self.text }
|
||||
guard !self.expanded, self.lines.count > Self.previewLineLimit else { return self.displayContent }
|
||||
return self.lines.prefix(Self.previewLineLimit).joined(separator: "\n") + "\n…"
|
||||
}
|
||||
|
||||
@@ -458,12 +467,7 @@ struct ChatTypingIndicatorBubble: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
TypingDots()
|
||||
if self.style == .standard {
|
||||
Text("OpenClaw is thinking…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, self.style == .standard ? 12 : 10)
|
||||
.padding(.horizontal, self.style == .standard ? 12 : 14)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
public struct OpenClawChatView: View {
|
||||
@@ -105,6 +108,9 @@ public struct OpenClawChatView: View {
|
||||
.padding(.top, Layout.messageListPaddingTop)
|
||||
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
||||
}
|
||||
#if !os(macOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
// Keep the scroll pinned to the bottom for new messages.
|
||||
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
||||
.onChange(of: self.scrollPosition) { _, position in
|
||||
@@ -123,6 +129,10 @@ public struct OpenClawChatView: View {
|
||||
// Ensure the message list claims vertical space on the first layout pass.
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.layoutPriority(1)
|
||||
.simultaneousGesture(
|
||||
TapGesture().onEnded {
|
||||
self.dismissKeyboardIfNeeded()
|
||||
})
|
||||
.onChange(of: self.viewModel.isLoading) { _, isLoading in
|
||||
guard !isLoading, !self.hasPerformedInitialScroll else { return }
|
||||
self.scrollPosition = self.scrollerBottomID
|
||||
@@ -406,6 +416,16 @@ public struct OpenClawChatView: View {
|
||||
}
|
||||
return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func dismissKeyboardIfNeeded() {
|
||||
#if canImport(UIKit)
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil,
|
||||
from: nil,
|
||||
for: nil)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatNoticeCard: View {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import Foundation
|
||||
|
||||
enum ToolResultTextFormatter {
|
||||
static func format(text: String, toolName: String?) -> String {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
|
||||
guard self.looksLikeJSON(trimmed),
|
||||
let data = trimmed.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data)
|
||||
else {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
let normalizedTool = toolName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return self.renderJSON(json, toolName: normalizedTool)
|
||||
}
|
||||
|
||||
private static func looksLikeJSON(_ value: String) -> Bool {
|
||||
guard let first = value.first else { return false }
|
||||
return first == "{" || first == "["
|
||||
}
|
||||
|
||||
private static func renderJSON(_ json: Any, toolName: String?) -> String {
|
||||
if let dict = json as? [String: Any] {
|
||||
return self.renderDictionary(dict, toolName: toolName)
|
||||
}
|
||||
if let array = json as? [Any] {
|
||||
if array.isEmpty { return "No items." }
|
||||
return "\(array.count) item\(array.count == 1 ? "" : "s")."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private static func renderDictionary(_ dict: [String: Any], toolName: String?) -> String {
|
||||
let status = (dict["status"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let errorText = self.firstString(in: dict, keys: ["error", "reason"])
|
||||
let messageText = self.firstString(in: dict, keys: ["message", "result", "detail"])
|
||||
|
||||
if status?.lowercased() == "error" || errorText != nil {
|
||||
if let errorText {
|
||||
return "Error: \(self.sanitizeError(errorText))"
|
||||
}
|
||||
if let messageText {
|
||||
return "Error: \(self.sanitizeError(messageText))"
|
||||
}
|
||||
return "Error"
|
||||
}
|
||||
|
||||
if toolName == "nodes", let summary = self.renderNodesSummary(dict) {
|
||||
return summary
|
||||
}
|
||||
|
||||
if let message = messageText {
|
||||
return message
|
||||
}
|
||||
|
||||
if let status, !status.isEmpty {
|
||||
return "Status: \(status)"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
private static func renderNodesSummary(_ dict: [String: Any]) -> String? {
|
||||
if let nodes = dict["nodes"] as? [[String: Any]] {
|
||||
if nodes.isEmpty { return "No nodes found." }
|
||||
var lines: [String] = []
|
||||
lines.append("\(nodes.count) node\(nodes.count == 1 ? "" : "s") found.")
|
||||
|
||||
for node in nodes.prefix(3) {
|
||||
let label = self.firstString(in: node, keys: ["displayName", "name", "nodeId"]) ?? "Node"
|
||||
var details: [String] = []
|
||||
|
||||
if let connected = node["connected"] as? Bool {
|
||||
details.append(connected ? "connected" : "offline")
|
||||
}
|
||||
if let platform = self.firstString(in: node, keys: ["platform"]) {
|
||||
details.append(platform)
|
||||
}
|
||||
if let version = self.firstString(in: node, keys: ["osVersion", "appVersion", "version"]) {
|
||||
details.append(version)
|
||||
}
|
||||
if let pairing = self.pairingDetail(node) {
|
||||
details.append(pairing)
|
||||
}
|
||||
|
||||
if details.isEmpty {
|
||||
lines.append("• \(label)")
|
||||
} else {
|
||||
lines.append("• \(label) - \(details.joined(separator: ", "))")
|
||||
}
|
||||
}
|
||||
|
||||
let extra = nodes.count - 3
|
||||
if extra > 0 {
|
||||
lines.append("... +\(extra) more")
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
if let pending = dict["pending"] as? [Any], let paired = dict["paired"] as? [Any] {
|
||||
return "Pairing requests: \(pending.count) pending, \(paired.count) paired."
|
||||
}
|
||||
|
||||
if let pending = dict["pending"] as? [Any] {
|
||||
if pending.isEmpty { return "No pending pairing requests." }
|
||||
return "\(pending.count) pending pairing request\(pending.count == 1 ? "" : "s")."
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func pairingDetail(_ node: [String: Any]) -> String? {
|
||||
if let paired = node["paired"] as? Bool, !paired {
|
||||
return "pairing required"
|
||||
}
|
||||
|
||||
for key in ["status", "state", "deviceStatus"] {
|
||||
if let raw = node[key] as? String, raw.lowercased().contains("pairing required") {
|
||||
return "pairing required"
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
if let value = dict[key] as? String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func sanitizeError(_ raw: String) -> String {
|
||||
var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if cleaned.contains("agent="),
|
||||
cleaned.contains("action="),
|
||||
let marker = cleaned.range(of: ": ")
|
||||
{
|
||||
cleaned = String(cleaned[marker.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
if let firstLine = cleaned.split(separator: "\n").first {
|
||||
cleaned = String(firstLine).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
if cleaned.count > 220 {
|
||||
cleaned = String(cleaned.prefix(217)) + "..."
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,39 @@ struct ChatMarkdownPreprocessorTests {
|
||||
#expect(result.images.count == 1)
|
||||
#expect(result.images.first?.image != nil)
|
||||
}
|
||||
|
||||
@Test func stripsInboundUntrustedContextBlocks() {
|
||||
let markdown = """
|
||||
Conversation info (untrusted metadata):
|
||||
```json
|
||||
{
|
||||
"message_id": "123",
|
||||
"sender": "openclaw-ios"
|
||||
}
|
||||
```
|
||||
|
||||
Sender (untrusted metadata):
|
||||
```json
|
||||
{
|
||||
"label": "Razor"
|
||||
}
|
||||
```
|
||||
|
||||
Razor?
|
||||
"""
|
||||
|
||||
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
|
||||
|
||||
#expect(result.cleaned == "Razor?")
|
||||
}
|
||||
|
||||
@Test func stripsLeadingTimestampPrefix() {
|
||||
let markdown = """
|
||||
[Fri 2026-02-20 18:45 GMT+1] How's it going?
|
||||
"""
|
||||
|
||||
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
|
||||
|
||||
#expect(result.cleaned == "How's it going?")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
@Suite("ToolResultTextFormatter")
|
||||
struct ToolResultTextFormatterTests {
|
||||
@Test func leavesPlainTextUntouched() {
|
||||
let result = ToolResultTextFormatter.format(text: "All good", toolName: "nodes")
|
||||
#expect(result == "All good")
|
||||
}
|
||||
|
||||
@Test func summarizesNodesListJSON() {
|
||||
let json = """
|
||||
{
|
||||
"ts": 1771610031380,
|
||||
"nodes": [
|
||||
{
|
||||
"displayName": "iPhone 16 Pro Max",
|
||||
"connected": true,
|
||||
"platform": "ios"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
let result = ToolResultTextFormatter.format(text: json, toolName: "nodes")
|
||||
#expect(result.contains("1 node found."))
|
||||
#expect(result.contains("iPhone 16 Pro Max"))
|
||||
#expect(result.contains("connected"))
|
||||
}
|
||||
|
||||
@Test func summarizesErrorJSONAndDropsAgentPrefix() {
|
||||
let json = """
|
||||
{
|
||||
"status": "error",
|
||||
"tool": "nodes",
|
||||
"error": "agent=main node=iPhone gateway=default action=invoke: pairing required"
|
||||
}
|
||||
"""
|
||||
|
||||
let result = ToolResultTextFormatter.format(text: json, toolName: "nodes")
|
||||
#expect(result == "Error: pairing required")
|
||||
}
|
||||
|
||||
@Test func suppressesUnknownStructuredPayload() {
|
||||
let json = """
|
||||
{
|
||||
"foo": "bar"
|
||||
}
|
||||
"""
|
||||
|
||||
let result = ToolResultTextFormatter.format(text: json, toolName: "nodes")
|
||||
#expect(result.isEmpty)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user