Improve built-in tool result presentation
This commit is contained in:
1 parent
fa38f89f85
commit
cbc34748c3
3 files changed
+1257
-20
No files matched your search
@@ -17,6 +17,66 @@ nonisolated enum AgentToolPresentation {
|
||||
let omittedCharacters: Int
|
||||
}
|
||||
|
||||
nonisolated struct Detail: Equatable, Sendable {
|
||||
var items: [DetailItem]
|
||||
}
|
||||
|
||||
nonisolated enum DetailItem: Equatable, Sendable {
|
||||
case field(DetailField)
|
||||
case message(String, DetailTint)
|
||||
case text(DetailTextBlock)
|
||||
case list(DetailListBlock)
|
||||
case groupedList(DetailGroupedList)
|
||||
}
|
||||
|
||||
nonisolated struct DetailField: Equatable, Sendable {
|
||||
var label: String
|
||||
var value: String
|
||||
var isMonospaced = false
|
||||
var url: String? = nil
|
||||
}
|
||||
|
||||
nonisolated enum DetailTint: Equatable, Sendable {
|
||||
case secondary, warning, success, failure
|
||||
}
|
||||
|
||||
nonisolated enum DetailTextStyle: Equatable, Sendable {
|
||||
case plain, monospaced
|
||||
}
|
||||
|
||||
nonisolated struct DetailTextBlock: Equatable, Sendable {
|
||||
var title: String?
|
||||
var text: String
|
||||
var style: DetailTextStyle
|
||||
}
|
||||
|
||||
nonisolated struct DetailListRow: Equatable, Sendable {
|
||||
var title: String
|
||||
var subtitle: String? = nil
|
||||
var detail: String? = nil
|
||||
var badges: [String] = []
|
||||
var symbol: String? = nil
|
||||
var tint: DetailTint = .secondary
|
||||
var url: String? = nil
|
||||
}
|
||||
|
||||
nonisolated struct DetailListBlock: Equatable, Sendable {
|
||||
var title: String?
|
||||
var rows: [DetailListRow]
|
||||
var emptyMessage: String? = nil
|
||||
}
|
||||
|
||||
nonisolated struct DetailListSection: Equatable, Sendable {
|
||||
var title: String
|
||||
var rows: [DetailListRow]
|
||||
}
|
||||
|
||||
nonisolated struct DetailGroupedList: Equatable, Sendable {
|
||||
var summary: String
|
||||
var sections: [DetailListSection]
|
||||
var isScrollable: Bool
|
||||
}
|
||||
|
||||
static func symbol(for card: AgentToolCard) -> String {
|
||||
return card.descriptor.presentation?.symbol ?? "wrench.and.screwdriver"
|
||||
}
|
||||
@@ -184,6 +244,724 @@ nonisolated enum AgentToolPresentation {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Projects protocol-shaped tool results into transcript-shaped content.
|
||||
/// The model and persistence keep the original JSON; only this projection
|
||||
/// is allowed to decide what a person sees.
|
||||
static func detail(for card: AgentToolCard) -> Detail? {
|
||||
guard let result = card.result else { return nil }
|
||||
|
||||
// MCP output is remote-defined and intentionally keeps the established
|
||||
// JSON presentation. Built-ins below never fall back to JSON syntax.
|
||||
if card.descriptor.target == .mcp {
|
||||
guard let output = detailOutput(
|
||||
from: result, presentation: card.descriptor.presentation,
|
||||
review: card.review
|
||||
) else { return nil }
|
||||
return Detail(items: [
|
||||
.text(DetailTextBlock(title: nil, text: output, style: .monospaced))
|
||||
])
|
||||
}
|
||||
|
||||
let raw = result.untrustedPayload ?? result.content
|
||||
guard let value = try? AgentJSONValue.decode(Data(raw.utf8)) else {
|
||||
guard let output = detailOutput(
|
||||
from: result, presentation: card.descriptor.presentation,
|
||||
review: card.review
|
||||
) else { return nil }
|
||||
let text = output.isEmpty ? String(localized: "Completed without output") : output
|
||||
return Detail(items: [
|
||||
.message(text, result.isError ? .failure : .secondary)
|
||||
])
|
||||
}
|
||||
|
||||
let arguments = card.invocation.call.arguments.objectValue ?? [:]
|
||||
let items: [DetailItem]? = switch card.descriptor.name {
|
||||
case "list_hosts": hostListing(value)
|
||||
case "ssh_exec", "ssh_wait": commandResult(value)
|
||||
case "terminal_suggest": terminalSuggestion(value, arguments: arguments)
|
||||
case "sftp_list": remoteFileList(value)
|
||||
case "sftp_stat": remoteFileStat(value)
|
||||
case "sftp_read": contentResult(value, title: String(localized: "Content"), includesPath: true)
|
||||
case "sftp_write": sftpWrite(value, arguments: arguments)
|
||||
case "sftp_mkdir": pathResult(value, result: String(localized: "Directory created"))
|
||||
case "sftp_move": moveResult(value)
|
||||
case "sftp_chmod": chmodResult(value)
|
||||
case "sftp_delete": deleteResult(value, arguments: arguments)
|
||||
case "fetch": fetchResult(value, arguments: arguments)
|
||||
case "web_search": webSearchResult(value)
|
||||
case "request_user_input": userInputResult(value, arguments: arguments)
|
||||
case "request_user_secret": secretResult(arguments: arguments)
|
||||
case "scratch_list": scratchList(value)
|
||||
case "scratch_read": scratchRead(value)
|
||||
case "scratch_write": scratchWrite(value)
|
||||
case "scratch_edit": scratchEdit(value)
|
||||
case "scratch_diff": scratchDiff(value)
|
||||
case "scratch_delete": scratchDelete(value)
|
||||
case "scratch_pull": scratchPull(value)
|
||||
case "scratch_push": scratchPush(value)
|
||||
case "present_plan": presentPlan(value)
|
||||
case "manage_tasks": taskResult(value)
|
||||
default: nil
|
||||
}
|
||||
|
||||
if let items { return Detail(items: items) }
|
||||
return Detail(items: genericItems(value))
|
||||
}
|
||||
|
||||
// MARK: - Built-in detail projections
|
||||
|
||||
private struct HostGroupProjection {
|
||||
let id: String
|
||||
let title: String
|
||||
}
|
||||
|
||||
private static func hostListing(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
let hostValues = object["hosts"]?.arrayValue ?? []
|
||||
let groupValues = object["groups"]?.arrayValue ?? []
|
||||
var groups: [HostGroupProjection] = []
|
||||
|
||||
func walk(_ rows: [AgentJSONValue], prefix: String = "") {
|
||||
for row in rows {
|
||||
guard let fields = row.objectValue else { continue }
|
||||
let name = nonempty(fields["name"]?.stringValue) ?? String(localized: "Unnamed group")
|
||||
let computed = prefix.isEmpty ? name : "\(prefix) / \(name)"
|
||||
let title = nonempty(fields["path"]?.stringValue) ?? computed
|
||||
if let id = fields["id"]?.stringValue {
|
||||
groups.append(HostGroupProjection(id: id, title: title))
|
||||
}
|
||||
walk(fields["children"]?.arrayValue ?? [], prefix: computed)
|
||||
}
|
||||
}
|
||||
walk(groupValues)
|
||||
|
||||
var grouped: [String: [DetailListRow]] = [:]
|
||||
var ungrouped: [DetailListRow] = []
|
||||
var temporary: [DetailListRow] = []
|
||||
var connected = 0
|
||||
|
||||
for hostValue in hostValues {
|
||||
guard let host = hostValue.objectValue else { continue }
|
||||
let state = host["connection_state"]?.stringValue ?? ""
|
||||
if state == "connected" { connected += 1 }
|
||||
let row = hostRow(host)
|
||||
if host["temporary"]?.boolValue == true {
|
||||
temporary.append(row)
|
||||
} else if let groupID = host["group_id"]?.stringValue,
|
||||
groups.contains(where: { $0.id == groupID })
|
||||
{
|
||||
grouped[groupID, default: []].append(row)
|
||||
} else {
|
||||
ungrouped.append(row)
|
||||
}
|
||||
}
|
||||
|
||||
guard !hostValues.isEmpty || !groups.isEmpty else {
|
||||
return [.message(String(localized: "No hosts available"), .secondary)]
|
||||
}
|
||||
|
||||
var summaryParts = [hostCount(hostValues.count)]
|
||||
if connected > 0 { summaryParts.append(String(localized: "\(connected) connected")) }
|
||||
if !temporary.isEmpty {
|
||||
summaryParts.append(String(localized: "\(temporary.count) temporary"))
|
||||
}
|
||||
var sections = groups.map { group in
|
||||
let rows = grouped[group.id] ?? []
|
||||
return DetailListSection(
|
||||
title: "\(group.title) · \(hostCount(rows.count))", rows: rows
|
||||
)
|
||||
}
|
||||
if !groups.isEmpty || !ungrouped.isEmpty {
|
||||
sections.append(DetailListSection(
|
||||
title: "\(String(localized: "Ungrouped")) · \(hostCount(ungrouped.count))",
|
||||
rows: ungrouped
|
||||
))
|
||||
}
|
||||
if !temporary.isEmpty {
|
||||
sections.append(DetailListSection(
|
||||
title: "\(String(localized: "Temporary")) · \(hostCount(temporary.count))",
|
||||
rows: temporary
|
||||
))
|
||||
}
|
||||
return [.groupedList(DetailGroupedList(
|
||||
summary: summaryParts.joined(separator: " · "), sections: sections,
|
||||
isScrollable: hostValues.count > 12
|
||||
))]
|
||||
}
|
||||
|
||||
private static func hostRow(_ host: [String: AgentJSONValue]) -> DetailListRow {
|
||||
let address = nonempty(host["address"]?.stringValue)
|
||||
let name = nonempty(host["display_name"]?.stringValue)
|
||||
?? address ?? String(localized: "Unnamed host")
|
||||
let state = host["connection_state"]?.stringValue ?? ""
|
||||
let statePresentation = connectionState(state)
|
||||
let subtitle = [address ?? String(localized: "Address unavailable"), statePresentation.label]
|
||||
.joined(separator: " · ")
|
||||
let tags = host["tags"]?.arrayValue?.compactMap { nonempty($0.stringValue) } ?? []
|
||||
return DetailListRow(
|
||||
title: name, subtitle: subtitle, badges: tags, symbol: "circle.fill",
|
||||
tint: statePresentation.tint
|
||||
)
|
||||
}
|
||||
|
||||
private static func connectionState(_ raw: String) -> (label: String, tint: DetailTint) {
|
||||
switch raw {
|
||||
case "connected": (String(localized: "Connected"), .success)
|
||||
case "connecting": (String(localized: "Connecting"), .warning)
|
||||
case "reconnecting": (String(localized: "Reconnecting"), .warning)
|
||||
case "disconnected": (String(localized: "Not connected"), .secondary)
|
||||
case "failed": (String(localized: "Failed"), .failure)
|
||||
default: (
|
||||
nonempty(humanized(raw))?.capitalized ?? String(localized: "Unknown"), .secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func commandResult(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
if let status = object["status"]?.stringValue {
|
||||
items.append(field("Status", statusLabel(status)))
|
||||
}
|
||||
if let code = object["exit_code"]?.integerValue {
|
||||
items.append(field("Exit code", String(code), monospaced: true))
|
||||
}
|
||||
if let signal = nonempty(object["signal"]?.stringValue) {
|
||||
items.append(field("Signal", signal, monospaced: true))
|
||||
}
|
||||
let stdout = object["stdout"]?.stringValue ?? ""
|
||||
let stderr = object["stderr"]?.stringValue ?? ""
|
||||
if !stdout.isEmpty { items.append(text("Standard output", stdout)) }
|
||||
if !stderr.isEmpty { items.append(text("Standard error", stderr)) }
|
||||
if stdout.isEmpty, stderr.isEmpty, object["note"] == nil {
|
||||
items.append(.message(String(localized: "No output"), .secondary))
|
||||
}
|
||||
if let note = nonempty(object["note"]?.stringValue) {
|
||||
items.append(.message(note, .warning))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func terminalSuggestion(
|
||||
_ value: AgentJSONValue, arguments: [String: AgentJSONValue]
|
||||
) -> [DetailItem] {
|
||||
let object = value.objectValue ?? [:]
|
||||
let command = arguments["command"]?.stringValue ?? object["command"]?.stringValue ?? ""
|
||||
var items: [DetailItem] = [text("Command", command)]
|
||||
if let explanation = nonempty(
|
||||
arguments["explanation"]?.stringValue ?? object["explanation"]?.stringValue
|
||||
) {
|
||||
items.append(.message(explanation, .secondary))
|
||||
}
|
||||
let assessment = CommandRiskClassifier.assess(command)
|
||||
items.append(field("Risk", assessment.risk.label))
|
||||
items.append(contentsOf: assessment.reasons.map { .message($0, .warning) })
|
||||
items.append(.message(String(localized: "Requires user action"), .warning))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func remoteFileList(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let rows = value.arrayValue else { return genericItems(value) }
|
||||
let items = rows.compactMap(remoteFileRow)
|
||||
return [
|
||||
.message(itemCount(items.count), .secondary),
|
||||
.list(DetailListBlock(
|
||||
title: nil, rows: items,
|
||||
emptyMessage: String(localized: "Directory is empty")
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
private static func remoteFileRow(_ value: AgentJSONValue) -> DetailListRow? {
|
||||
guard let object = value.objectValue else { return nil }
|
||||
let path = object["path"]?.stringValue ?? ""
|
||||
let name = nonempty(object["name"]?.stringValue)
|
||||
?? nonempty(path.split(separator: "/").last.map(String.init))
|
||||
?? String(localized: "Unnamed item")
|
||||
let kind = object["kind"]?.stringValue ?? ""
|
||||
var metadata = [humanized(kind).capitalized]
|
||||
if let bytes = object["size"]?.integerValue { metadata.append(byteCount(bytes)) }
|
||||
if let permissions = nonempty(object["permissions"]?.stringValue) {
|
||||
metadata.append(permissions)
|
||||
}
|
||||
let modified = object["modified_at"]?.stringValue.map(displayDate)
|
||||
return DetailListRow(
|
||||
title: name, subtitle: metadata.filter { !$0.isEmpty }.joined(separator: " · "),
|
||||
detail: modified, symbol: kind == "directory" ? "folder" : "doc"
|
||||
)
|
||||
}
|
||||
|
||||
private static func remoteFileStat(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "Kind", object["kind"]?.stringValue.map { humanized($0).capitalized })
|
||||
if let bytes = object["size"]?.integerValue { items.append(field("Size", byteCount(bytes))) }
|
||||
appendField(&items, "Permissions", object["permissions"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "Modified", object["modified_at"]?.stringValue.map(displayDate))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func contentResult(
|
||||
_ value: AgentJSONValue, title: String, includesPath: Bool
|
||||
) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
if includesPath { appendField(&items, "Path", object["path"]?.stringValue, monospaced: true) }
|
||||
let content = object["content"]?.stringValue ?? ""
|
||||
items.append(content.isEmpty
|
||||
? .message(String(localized: "Empty file"), .secondary)
|
||||
: .text(DetailTextBlock(title: title, text: content, style: .monospaced)))
|
||||
if let bytes = object["bytes"]?.integerValue { items.append(field("Size", byteCount(bytes))) }
|
||||
return items
|
||||
}
|
||||
|
||||
private static func sftpWrite(
|
||||
_ value: AgentJSONValue, arguments: [String: AgentJSONValue]
|
||||
) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
if let content = arguments["content"]?.stringValue {
|
||||
items.append(field("Size", byteCount(content.utf8.count)))
|
||||
}
|
||||
items.append(field("Result", String(localized: "Written")))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func pathResult(_ value: AgentJSONValue, result: String) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
items.append(field("Result", result))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func moveResult(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "From", object["source"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "To", object["destination"]?.stringValue, monospaced: true)
|
||||
return items
|
||||
}
|
||||
|
||||
private static func chmodResult(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "Permissions", object["mode"]?.stringValue, monospaced: true)
|
||||
return items
|
||||
}
|
||||
|
||||
private static func deleteResult(
|
||||
_ value: AgentJSONValue, arguments: [String: AgentJSONValue]
|
||||
) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "Kind", arguments["kind"]?.stringValue.map { humanized($0).capitalized })
|
||||
items.append(field("Result", String(localized: "Deleted")))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func fetchResult(
|
||||
_ value: AgentJSONValue, arguments: [String: AgentJSONValue]
|
||||
) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
if let url = nonempty(object["url"]?.stringValue) {
|
||||
items.append(.field(DetailField(
|
||||
label: String(localized: "URL"), value: url, isMonospaced: true, url: url
|
||||
)))
|
||||
}
|
||||
if let status = object["status"]?.integerValue {
|
||||
items.append(field("Status", String(status)))
|
||||
}
|
||||
if let headers = object["headers"]?.objectValue, !headers.isEmpty {
|
||||
let rows = headers.keys.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
.map { name in
|
||||
DetailListRow(title: name, subtitle: headers[name]?.stringValue ?? "")
|
||||
}
|
||||
items.append(.list(DetailListBlock(
|
||||
title: String(localized: "Response headers"), rows: rows
|
||||
)))
|
||||
}
|
||||
let method = arguments["method"]?.stringValue ?? "GET"
|
||||
if method != "HEAD" {
|
||||
let body = object["body"]?.stringValue ?? ""
|
||||
items.append(body.isEmpty
|
||||
? .message(String(localized: "Empty response body"), .secondary)
|
||||
: .text(DetailTextBlock(
|
||||
title: String(localized: "Response body"), text: body, style: .plain
|
||||
)))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func webSearchResult(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let values = value.arrayValue else { return genericItems(value) }
|
||||
let rows = values.compactMap { entry -> DetailListRow? in
|
||||
guard let object = entry.objectValue else { return nil }
|
||||
let url = nonempty(object["url"]?.stringValue)
|
||||
let title = nonempty(object["title"]?.stringValue) ?? url
|
||||
?? String(localized: "Untitled result")
|
||||
let snippet = nonempty(object["snippet"]?.stringValue)
|
||||
let published = object["published_at"]?.stringValue.map(displayDate)
|
||||
return DetailListRow(
|
||||
title: title, subtitle: url, detail: [snippet, published].compactMap { $0 }
|
||||
.joined(separator: "\n"), symbol: "link", url: url
|
||||
)
|
||||
}
|
||||
return [
|
||||
.message(searchResultCount(rows.count), .secondary),
|
||||
.list(DetailListBlock(
|
||||
title: nil, rows: rows, emptyMessage: String(localized: "No results")
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
private static func userInputResult(
|
||||
_ value: AgentJSONValue, arguments: [String: AgentJSONValue]
|
||||
) -> [DetailItem] {
|
||||
guard let answers = value.objectValue?["answers"]?.arrayValue else {
|
||||
return genericItems(value)
|
||||
}
|
||||
let answerByID = Dictionary(
|
||||
answers.compactMap { row -> (String, [String: AgentJSONValue])? in
|
||||
guard let fields = row.objectValue,
|
||||
let id = fields["question_id"]?.stringValue
|
||||
else { return nil }
|
||||
return (id, fields)
|
||||
},
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
let questions = arguments["questions"]?.arrayValue ?? []
|
||||
var items: [DetailItem] = []
|
||||
for questionValue in questions {
|
||||
guard let question = questionValue.objectValue else { continue }
|
||||
let id = question["id"]?.stringValue ?? ""
|
||||
let prompt = question["prompt"]?.stringValue ?? String(localized: "Question unavailable")
|
||||
let header = nonempty(question["header"]?.stringValue)
|
||||
let displayedQuestion = header.map { "\($0) — \(prompt)" } ?? prompt
|
||||
let answerFields = answerByID[id]
|
||||
let answer = answerFields?["answer"]
|
||||
let displayedAnswer: String
|
||||
if let answer, answer != .null {
|
||||
let selected = answerFields?["selected_option_ids"]?.arrayValue?
|
||||
.compactMap(\.stringValue) ?? []
|
||||
let selectedSet = Set(selected)
|
||||
let labels = question["options"]?.arrayValue?.compactMap { option -> String? in
|
||||
guard let fields = option.objectValue,
|
||||
let optionID = fields["id"]?.stringValue,
|
||||
selectedSet.contains(optionID)
|
||||
else { return nil }
|
||||
return nonempty(fields["label"]?.stringValue)
|
||||
} ?? []
|
||||
let custom = nonempty(answerFields?["custom_text"]?.stringValue)
|
||||
let structured = labels + [custom].compactMap { $0 }
|
||||
displayedAnswer = structured.isEmpty
|
||||
? nonempty(answer.stringValue) ?? String(localized: "Skipped")
|
||||
: structured.joined(separator: ", ")
|
||||
} else {
|
||||
displayedAnswer = String(localized: "Skipped")
|
||||
}
|
||||
items.append(field("Question", displayedQuestion))
|
||||
items.append(field("Answer", displayedAnswer))
|
||||
}
|
||||
return items.isEmpty ? genericItems(value) : items
|
||||
}
|
||||
|
||||
private static func secretResult(arguments: [String: AgentJSONValue]) -> [DetailItem] {
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Request", arguments["prompt"]?.stringValue)
|
||||
appendField(&items, "Purpose", arguments["purpose"]?.stringValue)
|
||||
if arguments["consumer_tool"]?.stringValue == "ssh_exec" {
|
||||
items.append(field("Consumer", String(localized: "SSH command")))
|
||||
} else {
|
||||
appendField(&items, "Consumer", arguments["consumer_tool"]?.stringValue.map(humanized))
|
||||
}
|
||||
items.append(field("Status", String(localized: "Secret received securely")))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func scratchList(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
let entries = object["entries"]?.arrayValue ?? []
|
||||
let entryCount = object["entry_count"]?.integerValue ?? entries.count
|
||||
let entryQuota = object["entry_quota"]?.integerValue
|
||||
let totalBytes = object["total_bytes"]?.integerValue ?? 0
|
||||
let byteQuota = object["byte_quota"]?.integerValue
|
||||
var summary = entryQuota.map { "\(entryCount) / \($0) entries" }
|
||||
?? String(localized: "\(entryCount) entries")
|
||||
if let byteQuota {
|
||||
summary += " · \(byteCount(totalBytes)) / \(byteCount(byteQuota))"
|
||||
} else {
|
||||
summary += " · \(byteCount(totalBytes))"
|
||||
}
|
||||
let rows = entries.compactMap { entry -> DetailListRow? in
|
||||
guard let fields = entry.objectValue else { return nil }
|
||||
let path = fields["path"]?.stringValue ?? String(localized: "Unnamed entry")
|
||||
let kind = fields["kind"]?.stringValue ?? ""
|
||||
var subtitle = humanized(kind).capitalized
|
||||
if let bytes = fields["bytes"]?.integerValue { subtitle += " · \(byteCount(bytes))" }
|
||||
return DetailListRow(
|
||||
title: path, subtitle: subtitle,
|
||||
detail: fields["modified_at"]?.stringValue.map(displayDate),
|
||||
symbol: kind == "directory" ? "folder" : "doc"
|
||||
)
|
||||
}
|
||||
return [
|
||||
.message(summary, .secondary),
|
||||
.list(DetailListBlock(
|
||||
title: nil, rows: rows, emptyMessage: String(localized: "Workspace is empty")
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
private static func scratchRead(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
let content = object["content"]?.stringValue ?? ""
|
||||
items.append(content.isEmpty
|
||||
? .message(String(localized: "Empty file"), .secondary)
|
||||
: text("Content", content))
|
||||
if let offset = object["offset"]?.integerValue,
|
||||
let returned = object["returned_lines"]?.integerValue,
|
||||
let total = object["total_lines"]?.integerValue
|
||||
{
|
||||
let start = returned == 0 ? offset : offset
|
||||
let end = returned == 0 ? offset : offset + returned - 1
|
||||
items.append(field("Lines", "\(start)–\(end) of \(total)"))
|
||||
}
|
||||
if let bytes = object["bytes"]?.integerValue { items.append(field("Size", byteCount(bytes))) }
|
||||
appendField(&items, "Line endings", object["line_ending"]?.stringValue?.uppercased())
|
||||
return items
|
||||
}
|
||||
|
||||
private static func scratchWrite(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
let outcome = object["created"]?.boolValue == true
|
||||
? String(localized: "Created") : String(localized: "Overwritten")
|
||||
items.append(field("Result", outcome))
|
||||
appendBytes(&items, "Size", object["bytes"])
|
||||
appendField(&items, "SHA-256", object["sha256"]?.stringValue, monospaced: true)
|
||||
appendBytes(&items, "Workspace usage", object["total_bytes"])
|
||||
return items
|
||||
}
|
||||
|
||||
private static func scratchEdit(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Path", object["path"]?.stringValue, monospaced: true)
|
||||
if let count = object["edits_applied"]?.integerValue {
|
||||
items.append(field("Changes", String(localized: "\(count) edits applied")))
|
||||
}
|
||||
if let diff = object["diff"]?.stringValue, !diff.isEmpty { items.append(text("Diff", diff)) }
|
||||
appendBytes(&items, "Size", object["bytes"])
|
||||
appendField(&items, "Line endings", object["line_ending"]?.stringValue?.uppercased())
|
||||
appendField(&items, "SHA-256", object["sha256"]?.stringValue, monospaced: true)
|
||||
appendBytes(&items, "Workspace usage", object["total_bytes"])
|
||||
return items
|
||||
}
|
||||
|
||||
private static func scratchDiff(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "From", object["path_a"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "To", object["path_b"]?.stringValue, monospaced: true)
|
||||
let diff = object["diff"]?.stringValue ?? ""
|
||||
let a = object["line_ending_a"]?.stringValue?.uppercased()
|
||||
let b = object["line_ending_b"]?.stringValue?.uppercased()
|
||||
if !diff.isEmpty {
|
||||
items.append(text("Diff", diff))
|
||||
} else if object["identical"]?.boolValue == true {
|
||||
items.append(.message(String(localized: "Files are identical"), .success))
|
||||
} else if let a, let b, a != b {
|
||||
items.append(.message(
|
||||
String(localized: "Text is identical; line endings differ: \(a) → \(b)"),
|
||||
.warning
|
||||
))
|
||||
} else {
|
||||
items.append(.message(String(localized: "No differences"), .secondary))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func scratchDelete(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items = pathResult(value, result: String(localized: "Deleted"))
|
||||
appendField(&items, "Kind", object["kind"]?.stringValue.map { humanized($0).capitalized })
|
||||
let count = object["entry_count"]?.integerValue
|
||||
let bytes = object["total_bytes"]?.integerValue
|
||||
if count != nil || bytes != nil {
|
||||
let parts = [count.map { String(localized: "\($0) entries") }, bytes.map(byteCount)]
|
||||
.compactMap { $0 }
|
||||
items.append(field("Workspace", parts.joined(separator: " · ")))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private static func scratchPull(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Remote path", object["remote_path"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "Scratch path", object["scratch_path"]?.stringValue, monospaced: true)
|
||||
appendBytes(&items, "Size", object["bytes"])
|
||||
items.append(field(
|
||||
"Content type",
|
||||
object["is_utf8"]?.boolValue == true
|
||||
? String(localized: "UTF-8 text") : String(localized: "Binary")
|
||||
))
|
||||
if let lines = object["lines"]?.integerValue { items.append(field("Lines", String(lines))) }
|
||||
appendField(&items, "SHA-256", object["sha256"]?.stringValue, monospaced: true)
|
||||
appendBytes(&items, "Workspace usage", object["total_bytes"])
|
||||
return items
|
||||
}
|
||||
|
||||
private static func scratchPush(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue else { return genericItems(value) }
|
||||
var items: [DetailItem] = []
|
||||
appendField(&items, "Scratch path", object["scratch_path"]?.stringValue, monospaced: true)
|
||||
appendField(&items, "Remote path", object["remote_path"]?.stringValue, monospaced: true)
|
||||
appendBytes(&items, "Size", object["bytes"])
|
||||
appendField(&items, "SHA-256", object["sha256"]?.stringValue, monospaced: true)
|
||||
items.append(field("Result", String(localized: "Written")))
|
||||
return items
|
||||
}
|
||||
|
||||
private static func presentPlan(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let path = value.objectValue?["scratch_path"]?.stringValue else {
|
||||
return genericItems(value)
|
||||
}
|
||||
return [.message(String(localized: "Plan presented from \(path)"), .success)]
|
||||
}
|
||||
|
||||
private static func taskResult(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
guard let object = value.objectValue,
|
||||
let total = object["tasks"]?.integerValue,
|
||||
let completed = object["completed"]?.integerValue
|
||||
else { return genericItems(value) }
|
||||
return [.message(
|
||||
String(localized: "\(total) tasks · \(completed) completed"), .secondary
|
||||
)]
|
||||
}
|
||||
|
||||
// MARK: - Generic human-readable fallback
|
||||
|
||||
private static func genericItems(_ value: AgentJSONValue) -> [DetailItem] {
|
||||
var fields: [DetailField] = []
|
||||
flatten(value, path: [], into: &fields)
|
||||
if fields.isEmpty {
|
||||
return [.message(String(localized: "Completed without output"), .secondary)]
|
||||
}
|
||||
return fields.map(DetailItem.field)
|
||||
}
|
||||
|
||||
private static func flatten(
|
||||
_ value: AgentJSONValue, path: [String], into fields: inout [DetailField]
|
||||
) {
|
||||
switch value {
|
||||
case .object(let object):
|
||||
for key in object.keys.sorted() {
|
||||
flatten(object[key] ?? .null, path: path + [humanized(key)], into: &fields)
|
||||
}
|
||||
case .array(let array):
|
||||
if array.isEmpty {
|
||||
fields.append(DetailField(label: label(path), value: String(localized: "None")))
|
||||
} else {
|
||||
for (index, entry) in array.enumerated() {
|
||||
flatten(entry, path: path + [String(index + 1)], into: &fields)
|
||||
}
|
||||
}
|
||||
case .string(let string):
|
||||
fields.append(DetailField(label: label(path), value: string))
|
||||
case .number(let number):
|
||||
let rendered = number.rounded() == number ? String(Int(number)) : String(number)
|
||||
fields.append(DetailField(label: label(path), value: rendered))
|
||||
case .bool(let bool):
|
||||
fields.append(DetailField(
|
||||
label: label(path), value: bool ? String(localized: "Yes") : String(localized: "No")
|
||||
))
|
||||
case .null:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private static func field(
|
||||
_ label: LocalizedStringResource, _ value: String, monospaced: Bool = false,
|
||||
url: String? = nil
|
||||
) -> DetailItem {
|
||||
.field(DetailField(
|
||||
label: String(localized: label), value: value, isMonospaced: monospaced, url: url
|
||||
))
|
||||
}
|
||||
|
||||
private static func appendField(
|
||||
_ items: inout [DetailItem], _ label: LocalizedStringResource, _ value: String?,
|
||||
monospaced: Bool = false
|
||||
) {
|
||||
guard let value = nonempty(value) else { return }
|
||||
items.append(field(label, value, monospaced: monospaced))
|
||||
}
|
||||
|
||||
private static func appendBytes(
|
||||
_ items: inout [DetailItem], _ label: LocalizedStringResource, _ value: AgentJSONValue?
|
||||
) {
|
||||
guard let bytes = value?.integerValue else { return }
|
||||
items.append(field(label, byteCount(bytes)))
|
||||
}
|
||||
|
||||
private static func text(_ title: LocalizedStringResource, _ value: String) -> DetailItem {
|
||||
.text(DetailTextBlock(
|
||||
title: String(localized: title), text: value, style: .monospaced
|
||||
))
|
||||
}
|
||||
|
||||
private static func nonempty(_ value: String?) -> String? {
|
||||
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private static func byteCount(_ count: Int) -> String {
|
||||
ByteCountFormatter.string(fromByteCount: Int64(count), countStyle: .file)
|
||||
}
|
||||
|
||||
private static func displayDate(_ raw: String) -> String {
|
||||
guard let date = try? Date.ISO8601FormatStyle().parse(raw) else { return raw }
|
||||
return date.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
|
||||
private static func statusLabel(_ status: String) -> String {
|
||||
switch status {
|
||||
case "connecting": String(localized: "Connecting")
|
||||
case "running": String(localized: "Running")
|
||||
case "exited": String(localized: "Exited")
|
||||
default: humanized(status).capitalized
|
||||
}
|
||||
}
|
||||
|
||||
private static func hostCount(_ count: Int) -> String {
|
||||
count == 1 ? String(localized: "1 host") : String(localized: "\(count) hosts")
|
||||
}
|
||||
|
||||
private static func itemCount(_ count: Int) -> String {
|
||||
count == 1 ? String(localized: "1 item") : String(localized: "\(count) items")
|
||||
}
|
||||
|
||||
private static func searchResultCount(_ count: Int) -> String {
|
||||
count == 1 ? String(localized: "1 result") : String(localized: "\(count) results")
|
||||
}
|
||||
|
||||
private static func label(_ path: [String]) -> String {
|
||||
let rendered = path.joined(separator: " · ")
|
||||
return rendered.isEmpty ? String(localized: "Result") : rendered.capitalized
|
||||
}
|
||||
|
||||
/// A bounded rendering projection. The full transformed output stays beside
|
||||
/// it for Copy, while the Text view only receives the small preview.
|
||||
static func preview(
|
||||
|
||||
@@ -597,26 +597,8 @@ private struct AgentToolRowView: View {
|
||||
}
|
||||
|
||||
if let result = card.result {
|
||||
if let output = AgentToolPresentation.detailOutput(
|
||||
from: result,
|
||||
presentation: card.descriptor.presentation,
|
||||
review: card.review
|
||||
) {
|
||||
let preview = AgentToolPresentation.preview(output)
|
||||
Text(preview.displayed)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(result.isError ? .red : .secondary)
|
||||
.textSelection(.enabled)
|
||||
|
||||
if preview.isShortened {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
Text("Output shortened for display")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
CopyButton(text: preview.full, title: "Copy Full Output")
|
||||
}
|
||||
}
|
||||
if let detail = AgentToolPresentation.detail(for: card) {
|
||||
AgentToolDetailView(detail: detail, isError: result.isError)
|
||||
}
|
||||
|
||||
if result.isTruncated {
|
||||
@@ -646,6 +628,184 @@ private struct AgentToolRowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured, deliberately low-chrome tool output. Protocol keys never reach
|
||||
/// this view: labels, sections and text styles have already been chosen by the
|
||||
/// presentation projection, leaving SwiftUI responsible only for hierarchy.
|
||||
private struct AgentToolDetailView: View {
|
||||
let detail: AgentToolPresentation.Detail
|
||||
let isError: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.s) {
|
||||
ForEach(Array(detail.items.enumerated()), id: \.offset) { _, item in
|
||||
switch item {
|
||||
case .field(let field):
|
||||
fieldView(field)
|
||||
case .message(let message, let tint):
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(color(tint))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.textSelection(.enabled)
|
||||
case .text(let block):
|
||||
textBlock(block)
|
||||
case .list(let list):
|
||||
listBlock(list)
|
||||
case .groupedList(let list):
|
||||
groupedList(list)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func fieldView(_ field: AgentToolPresentation.DetailField) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.s) {
|
||||
Text(field.label)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 76, alignment: .leading)
|
||||
if let rawURL = field.url, let url = URL(string: rawURL) {
|
||||
Link(field.value, destination: url)
|
||||
.font(field.isMonospaced ? .caption.monospaced() : .caption)
|
||||
} else {
|
||||
Text(field.value)
|
||||
.font(field.isMonospaced ? .caption.monospaced() : .caption)
|
||||
.foregroundStyle(isError ? .red : .primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func textBlock(_ block: AgentToolPresentation.DetailTextBlock) -> some View {
|
||||
if let title = block.title {
|
||||
Text(title)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
let preview = AgentToolPresentation.preview(block.text)
|
||||
Text(preview.displayed)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(block.style == .monospaced ? .caption.monospaced() : .caption)
|
||||
.foregroundStyle(isError ? .red : .secondary)
|
||||
.textSelection(.enabled)
|
||||
|
||||
if preview.isShortened {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
Text("Output shortened for display")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
CopyButton(text: preview.full, title: "Copy Full Output")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func listBlock(_ list: AgentToolPresentation.DetailListBlock) -> some View {
|
||||
if let title = list.title {
|
||||
Text(title)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if list.rows.isEmpty, let empty = list.emptyMessage {
|
||||
Text(empty).font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.s) {
|
||||
ForEach(Array(list.rows.enumerated()), id: \.offset) { _, row in
|
||||
detailRow(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func groupedList(_ list: AgentToolPresentation.DetailGroupedList) -> some View {
|
||||
Text(list.summary)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
if list.isScrollable {
|
||||
ScrollView {
|
||||
groupedListContents(list)
|
||||
}
|
||||
.frame(maxHeight: 360)
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
} else {
|
||||
groupedListContents(list)
|
||||
}
|
||||
}
|
||||
|
||||
private func groupedListContents(
|
||||
_ list: AgentToolPresentation.DetailGroupedList
|
||||
) -> some View {
|
||||
LazyVStack(alignment: .leading, spacing: Metrics.Space.m) {
|
||||
ForEach(Array(list.sections.enumerated()), id: \.offset) { _, section in
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.s) {
|
||||
Text(section.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach(Array(section.rows.enumerated()), id: \.offset) { _, row in
|
||||
detailRow(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func detailRow(_ row: AgentToolPresentation.DetailListRow) -> some View {
|
||||
HStack(alignment: .top, spacing: Metrics.Space.s) {
|
||||
if let symbol = row.symbol {
|
||||
Image(systemName: symbol)
|
||||
.font(.caption)
|
||||
.foregroundStyle(color(row.tint))
|
||||
.frame(width: Metrics.Sidebar.rowIcon)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
|
||||
if let rawURL = row.url, let url = URL(string: rawURL) {
|
||||
Link(row.title, destination: url)
|
||||
.font(.caption.weight(.medium))
|
||||
} else {
|
||||
Text(row.title).font(.caption.weight(.medium))
|
||||
}
|
||||
if let subtitle = row.subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let detail = row.detail, !detail.isEmpty {
|
||||
Text(detail)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if !row.badges.isEmpty {
|
||||
FlowLayout(spacing: Metrics.Space.xs, fillsWidth: false) {
|
||||
ForEach(Array(row.badges.enumerated()), id: \.offset) { _, badge in
|
||||
CardChip(text: badge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private func color(_ tint: AgentToolPresentation.DetailTint) -> Color {
|
||||
switch tint {
|
||||
case .secondary: .secondary
|
||||
case .warning: .orange
|
||||
case .success: .green
|
||||
case .failure: .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compaction
|
||||
|
||||
private struct AgentCompactionActivityRowView: View {
|
||||
|
||||
@@ -183,6 +183,292 @@ struct AgentToolPresentationTests {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func userInputDetailShowsOneQuestionAndAnswerFieldPerLine() throws {
|
||||
let arguments: [String: AgentJSONValue] = [
|
||||
"questions": .array([
|
||||
.object([
|
||||
"id": .string("storage"), "header": .string("Storage"),
|
||||
"prompt": .string("Which format should we use?"),
|
||||
"options": .array([
|
||||
.object(["id": .string("jsonb"), "label": .string("JSONB")])
|
||||
]),
|
||||
]),
|
||||
.object([
|
||||
"id": .string("region"), "prompt": .string("Which region?")
|
||||
]),
|
||||
])
|
||||
]
|
||||
var card = makeCard(
|
||||
name: "request_user_input", target: .user, arguments: arguments,
|
||||
presentation: AgentBuiltInTools.requestUserInput().descriptor.presentation
|
||||
)
|
||||
card.state = .completed
|
||||
card.result = AgentToolResult(
|
||||
callID: "call",
|
||||
content: AgentJSONValue.object([
|
||||
"answers": .array([
|
||||
.object([
|
||||
"question_id": .string("storage"), "answer": .string("With audit log"),
|
||||
"selected_option_ids": .array([.string("jsonb")]),
|
||||
"custom_text": .string("With audit log"),
|
||||
]),
|
||||
.object([
|
||||
"question_id": .string("region"), "answer": .null,
|
||||
"selected_option_ids": .array([]), "custom_text": .null,
|
||||
]),
|
||||
])
|
||||
]).encodedString
|
||||
)
|
||||
|
||||
let detail = try #require(AgentToolPresentation.detail(for: card))
|
||||
#expect(detail.items == [
|
||||
.field(.init(label: "Question", value: "Storage — Which format should we use?")),
|
||||
.field(.init(label: "Answer", value: "JSONB, With audit log")),
|
||||
.field(.init(label: "Question", value: "Which region?")),
|
||||
.field(.init(label: "Answer", value: "Skipped")),
|
||||
])
|
||||
#expect(!String(describing: detail).contains("selected_option_ids"))
|
||||
#expect(!String(describing: detail).contains("jsonb"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func hostListingDetailPreservesGroupsAndHidesProtocolIdentifiers() throws {
|
||||
let productionID = "11111111-1111-1111-1111-111111111111"
|
||||
let childID = "22222222-2222-2222-2222-222222222222"
|
||||
let result = AgentJSONValue.object([
|
||||
"groups": .array([
|
||||
.object([
|
||||
"id": .string(productionID), "name": .string("Production"),
|
||||
"path": .string("Production"),
|
||||
"children": .array([
|
||||
.object([
|
||||
"id": .string(childID), "name": .string("Edge"),
|
||||
"path": .string("Production / Edge"), "children": .array([]),
|
||||
])
|
||||
]),
|
||||
])
|
||||
]),
|
||||
"hosts": .array([
|
||||
hostJSON(
|
||||
id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", name: "API Server",
|
||||
address: "deploy@api.example.com", state: "connected",
|
||||
tags: ["prod", "nginx"], groupID: productionID
|
||||
),
|
||||
hostJSON(
|
||||
id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", name: "Loose Box",
|
||||
address: "root@10.0.0.2", state: "failed", tags: [],
|
||||
groupID: "ffffffff-ffff-ffff-ffff-ffffffffffff"
|
||||
),
|
||||
hostJSON(
|
||||
id: "cccccccc-cccc-cccc-cccc-cccccccccccc", name: "Current Terminal",
|
||||
address: "root@10.0.0.8", state: "reconnecting", tags: [],
|
||||
temporary: true
|
||||
),
|
||||
]),
|
||||
])
|
||||
var card = makeCard(name: "list_hosts", target: .host)
|
||||
card.state = .completed
|
||||
card.result = AgentToolResult(callID: "call", content: result.encodedString)
|
||||
|
||||
let detail = try #require(AgentToolPresentation.detail(for: card))
|
||||
guard case .groupedList(let listing) = try #require(detail.items.first) else {
|
||||
Issue.record("Expected a grouped host listing")
|
||||
return
|
||||
}
|
||||
#expect(listing.summary == "3 hosts · 1 connected · 1 temporary")
|
||||
#expect(listing.sections.map(\.title) == [
|
||||
"Production · 1 host", "Production / Edge · 0 hosts",
|
||||
"Ungrouped · 1 host", "Temporary · 1 host",
|
||||
])
|
||||
#expect(listing.sections[0].rows[0].title == "API Server")
|
||||
#expect(listing.sections[0].rows[0].subtitle == "deploy@api.example.com · Connected")
|
||||
#expect(listing.sections[0].rows[0].badges == ["prod", "nginx"])
|
||||
#expect(listing.sections[2].rows[0].subtitle == "root@10.0.0.2 · Failed")
|
||||
#expect(listing.sections[3].rows[0].subtitle == "root@10.0.0.8 · Reconnecting")
|
||||
let rendered = String(describing: detail)
|
||||
#expect(!rendered.contains(productionID))
|
||||
#expect(!rendered.contains("aaaaaaaa-aaaa"))
|
||||
#expect(!rendered.contains("group_id"))
|
||||
#expect(!rendered.contains("connection_state"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func hostListingBecomesScrollablePastTwelveHosts() throws {
|
||||
let hosts = (0..<13).map { index in
|
||||
hostJSON(
|
||||
id: UUID().uuidString, name: "Host \(index)",
|
||||
address: "host\(index).example.com", state: "disconnected", tags: []
|
||||
)
|
||||
}
|
||||
var card = makeCard(name: "list_hosts", target: .host)
|
||||
card.result = AgentToolResult(
|
||||
callID: "call",
|
||||
content: AgentJSONValue.object([
|
||||
"groups": .array([]), "hosts": .array(hosts),
|
||||
]).encodedString
|
||||
)
|
||||
|
||||
let detail = try #require(AgentToolPresentation.detail(for: card))
|
||||
guard case .groupedList(let listing) = try #require(detail.items.first) else {
|
||||
Issue.record("Expected a grouped host listing")
|
||||
return
|
||||
}
|
||||
#expect(listing.isScrollable)
|
||||
#expect(listing.sections[0].rows.count == 13)
|
||||
}
|
||||
|
||||
@Test
|
||||
func builtInFallbackUsesFieldsWhileMCPKeepsJSON() throws {
|
||||
let payload = AgentJSONValue.object([
|
||||
"internal_flag": .bool(true), "count": .number(2),
|
||||
]).encodedString
|
||||
var builtIn = makeCard(name: "future_tool", target: .local)
|
||||
builtIn.result = AgentToolResult(callID: "call", content: payload)
|
||||
let builtInDetail = try #require(AgentToolPresentation.detail(for: builtIn))
|
||||
#expect(builtInDetail.items == [
|
||||
.field(.init(label: "Count", value: "2")),
|
||||
.field(.init(label: "Internal Flag", value: "Yes")),
|
||||
])
|
||||
#expect(!String(describing: builtInDetail).contains("{"))
|
||||
|
||||
var mcp = makeCard(
|
||||
name: "remote_tool", target: .mcp,
|
||||
presentation: .init(
|
||||
symbol: "puzzlepiece.extension", activity: .label("remote tool"), output: .json
|
||||
)
|
||||
)
|
||||
mcp.result = AgentToolResult(callID: "call", content: payload)
|
||||
let mcpDetail = try #require(AgentToolPresentation.detail(for: mcp))
|
||||
guard case .text(let block) = try #require(mcpDetail.items.first) else {
|
||||
Issue.record("Expected MCP JSON text")
|
||||
return
|
||||
}
|
||||
#expect(block.text.contains("{"))
|
||||
#expect(block.text.contains("\"internal_flag\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
func everyBuiltInJSONToolProducesStructuredDetailWithoutJSONSyntax() throws {
|
||||
let file = AgentJSONValue.object([
|
||||
"path": .string("/tmp/a"), "name": .string("a"), "kind": .string("file"),
|
||||
"size": .number(4), "permissions": .string("644"), "modified_at": .null,
|
||||
])
|
||||
let cases: [(String, AgentToolDescriptor.Target, [String: AgentJSONValue], AgentJSONValue)] = [
|
||||
("list_hosts", .host, [:], .object(["groups": .array([]), "hosts": .array([])])),
|
||||
("ssh_exec", .host, [:], .object([
|
||||
"status": .string("exited"), "stdout": .string("ok"),
|
||||
"stderr": .string(""), "exit_code": .number(0),
|
||||
])),
|
||||
("ssh_wait", .host, [:], .object([
|
||||
"status": .string("running"), "stdout": .string(""),
|
||||
"stderr": .string(""), "note": .string("Still running"),
|
||||
])),
|
||||
("terminal_suggest", .user, ["command": .string("pwd")], .object([
|
||||
"command": .string("pwd"), "requires_user_action": .bool(true),
|
||||
])),
|
||||
("sftp_list", .host, [:], .array([file])),
|
||||
("sftp_stat", .host, [:], file),
|
||||
("sftp_read", .host, [:], .object([
|
||||
"path": .string("/tmp/a"), "content": .string("text"), "bytes": .number(4),
|
||||
])),
|
||||
("sftp_write", .host, ["content": .string("text")], .object([
|
||||
"path": .string("/tmp/a"), "written": .bool(true),
|
||||
])),
|
||||
("sftp_mkdir", .host, [:], .object([
|
||||
"path": .string("/tmp/new"), "created": .bool(true),
|
||||
])),
|
||||
("sftp_move", .host, [:], .object([
|
||||
"source": .string("/tmp/a"), "destination": .string("/tmp/b"),
|
||||
"moved": .bool(true),
|
||||
])),
|
||||
("sftp_chmod", .host, [:], .object([
|
||||
"path": .string("/tmp/a"), "mode": .string("644"),
|
||||
])),
|
||||
("sftp_delete", .host, ["kind": .string("file")], .object([
|
||||
"path": .string("/tmp/a"), "deleted": .bool(true),
|
||||
])),
|
||||
("fetch", .network, ["method": .string("GET")], .object([
|
||||
"url": .string("https://example.com"), "status": .number(200),
|
||||
"headers": .object(["content-type": .string("text/plain")]),
|
||||
"body": .string("hello"),
|
||||
])),
|
||||
("web_search", .network, [:], .array([
|
||||
.object([
|
||||
"title": .string("Example"), "url": .string("https://example.com"),
|
||||
"snippet": .string("Snippet"), "published_at": .null,
|
||||
])
|
||||
])),
|
||||
("request_user_input", .user, [
|
||||
"questions": .array([
|
||||
.object(["id": .string("q"), "prompt": .string("Question?")])
|
||||
])
|
||||
], .object([
|
||||
"answers": .array([
|
||||
.object(["question_id": .string("q"), "answer": .string("Answer")])
|
||||
])
|
||||
])),
|
||||
("request_user_secret", .user, [
|
||||
"prompt": .string("Password"), "purpose": .string("Login"),
|
||||
"consumer_tool": .string("ssh_exec"),
|
||||
], .object(["secret_handle": .string("hidden"), "single_use": .bool(true)])),
|
||||
("scratch_list", .local, [:], .object([
|
||||
"entries": .array([]), "entry_count": .number(0),
|
||||
"entry_quota": .number(100), "total_bytes": .number(0),
|
||||
"byte_quota": .number(1000),
|
||||
])),
|
||||
("scratch_read", .local, [:], .object([
|
||||
"path": .string("a"), "content": .string("text"), "bytes": .number(4),
|
||||
"offset": .number(1), "returned_lines": .number(1),
|
||||
"total_lines": .number(1), "line_ending": .string("lf"),
|
||||
])),
|
||||
("scratch_write", .local, [:], .object([
|
||||
"path": .string("a"), "bytes": .number(4), "sha256": .string("abcd"),
|
||||
"created": .bool(true), "total_bytes": .number(4),
|
||||
])),
|
||||
("scratch_edit", .local, [:], .object([
|
||||
"path": .string("a"), "edits_applied": .number(1),
|
||||
"diff": .string("-old\n+new"), "bytes": .number(3),
|
||||
"line_ending": .string("lf"), "sha256": .string("abcd"),
|
||||
"total_bytes": .number(3),
|
||||
])),
|
||||
("scratch_diff", .local, [:], .object([
|
||||
"path_a": .string("a"), "path_b": .string("b"),
|
||||
"identical": .bool(true), "diff": .string(""),
|
||||
"line_ending_a": .string("lf"), "line_ending_b": .string("lf"),
|
||||
])),
|
||||
("scratch_delete", .local, [:], .object([
|
||||
"path": .string("a"), "kind": .string("file"), "deleted": .bool(true),
|
||||
"entry_count": .number(0), "total_bytes": .number(0),
|
||||
])),
|
||||
("scratch_pull", .host, [:], .object([
|
||||
"remote_path": .string("/tmp/a"), "scratch_path": .string("a"),
|
||||
"bytes": .number(4), "sha256": .string("abcd"),
|
||||
"is_utf8": .bool(true), "lines": .number(1), "total_bytes": .number(4),
|
||||
])),
|
||||
("scratch_push", .host, [:], .object([
|
||||
"scratch_path": .string("a"), "remote_path": .string("/tmp/a"),
|
||||
"bytes": .number(4), "sha256": .string("abcd"), "written": .bool(true),
|
||||
])),
|
||||
("present_plan", .user, [:], .object([
|
||||
"presented": .bool(true), "scratch_path": .string("plan.md"),
|
||||
"plan": .string("Plan"),
|
||||
])),
|
||||
("manage_tasks", .user, [:], .object([
|
||||
"tasks": .number(3), "completed": .number(1),
|
||||
])),
|
||||
]
|
||||
|
||||
for (name, target, arguments, result) in cases {
|
||||
var card = makeCard(name: name, target: target, arguments: arguments)
|
||||
card.result = AgentToolResult(callID: "call", content: result.encodedString)
|
||||
let detail = try #require(AgentToolPresentation.detail(for: card), "Missing \(name)")
|
||||
let rendered = String(describing: detail)
|
||||
#expect(!rendered.contains("{"), "\(name) exposed JSON")
|
||||
#expect(!rendered.contains("}"), "\(name) exposed JSON")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func finishedBatchSummaryCountsRepeatedActivitiesAndPreservesFailures() {
|
||||
let questionPresentation = AgentBuiltInTools.requestUserInput().descriptor.presentation
|
||||
@@ -479,4 +765,17 @@ struct AgentToolPresentationTests {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func hostJSON(
|
||||
id: String, name: String, address: String, state: String, tags: [String],
|
||||
groupID: String? = nil, temporary: Bool = false
|
||||
) -> AgentJSONValue {
|
||||
.object([
|
||||
"id": .string(id), "display_name": .string(name), "address": .string(address),
|
||||
"tags": .array(tags.map(AgentJSONValue.string)),
|
||||
"connection_state": .string(state), "temporary": .bool(temporary),
|
||||
"group_id": groupID.map(AgentJSONValue.string) ?? .null,
|
||||
"group_path": .null,
|
||||
])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user