ios: hide raw tool entries in Agent transcript

This commit is contained in:
2026-10-14 15:40:37 -07:00
parent 56279fba7f
commit a21c4fe5cf
5 changed files with 203 additions and 87 deletions
@@ -203,6 +203,23 @@ struct AgentRoomState: Equatable, Sendable {
}
}
// Filter only the transcript projection, before windowing. Keep the room's raw
// messages and cursors intact for upserts, replay, unread state and activity.
struct AgentTranscript {
let messages: [Message]
let startIndex: Int
var displayed: [Message] { Array(messages.dropFirst(startIndex)) }
init(messages: [Message], startID: String? = nil, limit: Int = 100) {
let visible = messages.filter {
$0.role != .tool && $0.metadata?.objectValue?["kind"]?.stringValue != "tool_call"
}
self.messages = visible
startIndex = startID.flatMap { id in visible.firstIndex(where: { $0.id == id }) }
?? max(0, visible.count - limit)
}
}
// The transcript latch is independent of the truthful sidebar/worker activity.
struct AgentPresentation {
private(set) var suppressed = false
@@ -22,9 +22,10 @@ struct SybilAgentView: View {
}
var body: some View {
let rendered = room.snapshot
let allMessages = room.messages
let startIndex = historyStartID.flatMap { id in allMessages.firstIndex(where: { $0.id == id }) } ?? max(0, allMessages.count - historyLimit)
let displayed = Array(allMessages.dropFirst(startIndex))
let transcript = AgentTranscript(messages: room.messages, startID: historyStartID, limit: historyLimit)
let allMessages = transcript.messages
let startIndex = transcript.startIndex
let displayed = transcript.displayed
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
@@ -51,13 +52,7 @@ struct SybilAgentView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
}
Group {
if message.isToolCallLog {
AgentToolCard(message: message)
} else {
AgentMessageRow(message: message, retry: { room.retry(message.id) })
}
}
AgentMessageRow(message: message, retry: { room.retry(message.id) })
.background(GeometryReader { geometry in
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
})
@@ -110,8 +105,8 @@ struct SybilAgentView: View {
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? previousAnchor : nil)
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
// Restore the exact pixel offset after insertion, tool
// expansion or text reflow. Normalize the saved frames to
// Restore the exact pixel offset after insertion or text
// reflow. Normalize the saved frames to
// the requested offset so the next layout cannot undo it.
let requested = scrollMetrics.offset + frame.minY - anchor.offset
let target = min(max(requested, -scrollMetrics.topInset), max(-scrollMetrics.topInset, scrollMetrics.contentHeight - scrollMetrics.height + scrollMetrics.bottomInset))
@@ -260,57 +255,6 @@ private struct AgentMessageRow: View {
}
}
struct AgentToolCard: View {
var message: Message
@State private var expanded = false
var body: some View {
if let metadata = message.toolCallMetadata {
VStack(alignment: .leading, spacing: 0) {
Button {
expanded.toggle()
} label: {
HStack(spacing: 8) {
ToolCallActivityChip(metadata: metadata, fallbackContent: message.content, createdAt: message.createdAt, compactLayout: true)
.allowsHitTesting(false)
Spacer(minLength: 0)
Image(systemName: expanded ? "chevron.down" : "chevron.right")
.font(.system(size: 13, weight: .semibold))
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("agent-tool-\(metadata.toolName ?? "tool")")
.accessibilityValue(expanded ? "Expanded" : "Collapsed")
if expanded {
VStack(alignment: .leading, spacing: 12) {
if let args = metadata.args { detail("Input", text: pretty(args)) }
if let error = metadata.error { detail("Error", text: error) }
if let result = metadata.result { detail("Result", text: pretty(result)) }
else if let preview = metadata.resultPreview { detail("Result", text: preview) }
if metadata.args == nil && metadata.result == nil && metadata.resultPreview == nil && metadata.error == nil {
Text(message.content).font(.sybil(.caption))
}
}.padding(.top, 8).frame(maxWidth: .infinity, alignment: .leading)
}
}
.foregroundStyle(SybilTheme.textMuted)
.padding(8)
.background(SybilTheme.surface.opacity(0.8), in: RoundedRectangle(cornerRadius: 14))
}
}
private func detail(_ title: String, text: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.sybil(.caption, weight: .semibold)).accessibilityIdentifier("agent-tool-\(title.lowercased())")
Text(text).font(.system(size: 12, design: .monospaced)).textSelection(.enabled)
}
}
private func pretty(_ value: JSONValue) -> String {
if let text = value.stringValue { return text }
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return (try? encoder.encode(value)).map { String(decoding: $0, as: UTF8.self) } ?? ""
}
}
// Animate both occupied height and content, so incoming rows push older history up.
private struct AgentGrowingLayout: Layout {
var progress: CGFloat
@@ -97,6 +97,161 @@ private func eventually(_ predicate: () -> Bool) async -> Bool {
}
struct AgentTests {
@Test @MainActor func mixedLoadedHistoryFiltersOnlyToolRows() async throws {
var user = agentMessage("user", entry: "1", role: .user)
user.content = "Explain the tool_call and tool result."
let call = agentMessage("call", entry: "2", role: .tool, info: ["status": .string("initiated")])
var reply = agentMessage("reply", entry: "3")
reply.content = "The search tool failed; here is what you can do."
// Role-only results still need hiding even without tool-call metadata.
var result = agentMessage("result", entry: "4", role: .tool)
result.metadata = nil
let markedCall = agentMessage("marked-call", entry: "5", info: ["kind": .string("tool_call")])
var plainReply = agentMessage("plain-reply", entry: "6")
plainReply.metadata = nil
let failure = agentMessage("failure", entry: "7", info: ["status": .string("failed"), "error": .string("Please try again")])
let terminal = agentMessage("terminal", entry: "8", role: .tool, info: ["status": .string("failed"), "error": .string("Raw tool failure"), "resultPreview": .string("Raw result")])
let history = [user, call, reply, result, markedCall, plainReply, failure, terminal].enumerated().map { index, message in
var message = message
message.createdAt.addTimeInterval(Double(index))
return message
}
let client = TestAgentClient(pages: ["0": agentPage(Array(history.dropLast()), cursor: "7", more: true), "7": agentPage([history[7]], cursor: "8")])
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
#expect(await client.requestedCursors() == ["0", "7"])
#expect(await client.connectedAfters() == ["8"])
let transcript = AgentTranscript(messages: room.messages)
#expect(transcript.displayed == [history[0], history[2], history[5], history[6]])
#expect(room.snapshot.room.messages == history)
#expect(room.messages.count == 8)
#expect(room.snapshot.room.cursor == "8")
#expect(room.snapshot.room.readCursor == "0")
#expect(room.unreadCount == 2)
#expect(room.snapshot.entranceIDs.isEmpty)
// Shared regular-chat tool metadata remains available and unchanged.
#expect(terminal.isToolCallLog)
#expect(terminal.toolCallMetadata?.resultPreview == "Raw result")
}
@Test func transcriptWindowsAndAnchorsUseVisibleRows() {
var raw: [Message] = [], visible: [Message] = []
for index in 1...160 {
var message = agentMessage("reply-\(index)", entry: "\(index * 2)")
message.createdAt.addTimeInterval(Double(index * 2) * 86_400)
var tool = agentMessage("tool-\(index)", entry: "\(index * 2 + 1)", role: .tool)
// Tools on separate dates must not introduce their own separators.
tool.createdAt = message.createdAt.addingTimeInterval(86_400)
raw += [message, tool]; visible.append(message)
}
let initial = AgentTranscript(messages: raw)
#expect(initial.startIndex == 60)
#expect(initial.displayed == Array(visible.suffix(100)))
let anchorID = initial.displayed.first?.id
let hidden = agentMessage("late-tool", entry: "400", role: .tool)
let updated = AgentTranscript(messages: raw + [hidden], startID: anchorID)
#expect(updated.displayed == initial.displayed)
let earlierID = initial.messages[max(0, initial.startIndex - 100)].id
let expanded = AgentTranscript(messages: raw, startID: earlierID, limit: 200)
#expect(expanded.displayed == visible)
#expect(expanded.displayed.firstIndex(where: { $0.id == anchorID }) == 60)
let hiddenAnchor = AgentTranscript(messages: raw, startID: "tool-160")
#expect(hiddenAnchor.displayed == initial.displayed)
let onlyTools = AgentTranscript(messages: raw.filter { $0.role == .tool })
#expect(onlyTools.messages.isEmpty)
#expect(onlyTools.displayed.isEmpty)
#expect(onlyTools.startIndex == 0)
}
@Test @MainActor func liveToolUpsertsPreserveRawHistoryCursorsAndActivity() async throws {
let user = agentMessage("user", entry: "1", role: .user)
let reply = agentMessage("reply", entry: "2")
let client = TestAgentClient(pages: ["0": agentPage([user, reply], cursor: "2")])
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
let queued = agentMessage("queued", entry: "0", role: .user, info: ["status": .string("queued")])
var activity = AgentActivity()
activity.isThinking = true; activity.backgroundWorkers = 2; activity.queued = 1
activity.queuedMessages = [queued]
activity.wait = .init(due: 1_800_000_000_000, reason: "Waiting for background work")
activity.error = "A worker needs attention"
try room.receive(.activity("root", activity)); room.flush()
#expect(await eventually { room.snapshot.isTyping })
let visible = AgentTranscript(messages: room.messages).displayed
#expect(Set(visible.map(\.id)) == ["user", "reply", "queued"])
let start = agentMessage("tool", entry: "3", role: .tool, info: ["status": .string("initiated"), "toolName": .string("search"), "args": .object(["query": .string("fixture")]), "startedAt": .string("start")])
let completed = agentMessage("tool", entry: "4", role: .tool, info: ["status": .string("completed"), "resultPreview": .string("Raw preview")])
let result = agentMessage("tool", entry: "5", role: .tool, info: ["status": .string("completed"), "result": .object(["matches": .number(3)])])
for (message, cursor) in [(start, "3"), (completed, "4"), (result, "5"), (start, "3")] {
try room.receive(.messages(agentPage([message], cursor: cursor))); room.flush()
#expect(AgentTranscript(messages: room.messages).displayed == visible)
#expect(room.snapshot.room.messages.count == 3)
#expect(room.unreadCount == 1)
#expect(room.snapshot.room.readCursor == "0")
#expect(room.snapshot.isTyping)
#expect(room.snapshot.room.activity == activity)
}
let tool = try #require(room.snapshot.room.messages.first(where: { $0.id == "tool" })?.toolCallMetadata)
#expect(tool.status == "completed")
#expect(tool.args == .object(["query": .string("fixture")]))
#expect(tool.resultPreview == "Raw preview")
#expect(tool.result == .object(["matches": .number(3)]))
#expect(tool.startedAt == "start")
#expect(room.snapshot.room.cursor == "5")
// The rendered snapshot's cursor includes hidden tool updates. A later
// reply must remain unread when only that captured cursor is acknowledged.
room.isSelected = true
let capturedCursor = room.snapshot.room.cursor
let lateReply = agentMessage("late-reply", entry: "6")
let lateTool = agentMessage("late-tool", entry: "7", role: .tool)
try room.receive(.messages(agentPage([lateReply, lateTool], cursor: "7"))); room.flush()
room.markRead(renderedCursor: capturedCursor)
#expect(await eventually { room.snapshot.room.readCursor == "5" })
#expect(room.snapshot.room.cursor == "7")
#expect(room.unreadCount == 1)
#expect(AgentTranscript(messages: room.messages).displayed == visible + [lateReply])
#expect(!room.snapshot.isTyping)
#expect(room.isBusy)
let socket = try #require(await client.latestSocket())
await socket.push(#"{"type":"error","error":"Room connection unavailable"}"#)
#expect(await eventually { room.error == "Room connection unavailable" })
#expect(AgentTranscript(messages: room.messages).displayed == visible + [lateReply])
#expect(room.snapshot.room.messages.count == 5)
}
@Test @MainActor func failedSendDetailsAndRetryStayVisibleWithHiddenTools() async throws {
let tool = agentMessage("tool", entry: "1", role: .tool)
let client = TestAgentClient(pages: ["0": agentPage([tool], cursor: "1")])
await client.setFailures(submit: true)
let memory = MemoryOutbox()
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
#expect(room.send("Please retry the search tool"))
#expect(await eventually { room.outbox.first?.status == "failed" })
let failed = try #require(AgentTranscript(messages: room.messages).displayed.first)
let requestID = try #require(room.outbox.first?.requestID)
#expect(failed.role == .user)
#expect(failed.content == "Please retry the search tool")
#expect(failed.metadata?.objectValue?["status"] == .string("failed"))
#expect(failed.metadata?.objectValue?["error"] == .string("Lost acknowledgement"))
#expect(AgentTranscript(messages: room.messages).displayed.count == 1)
#expect(memory.read().first?.requestID == requestID)
await client.setFailures()
room.retry(failed.id)
#expect(await eventually { room.outbox.first?.status == "queued" })
let retried = try #require(AgentTranscript(messages: room.messages).displayed.first)
#expect(retried.id == failed.id)
#expect(retried.createdAt == failed.createdAt)
#expect(retried.metadata?.objectValue?["status"] == .string("queued"))
#expect(retried.metadata?.objectValue?["error"] == nil)
#expect(await client.submittedIDs() == [requestID, requestID])
#expect(room.snapshot.room.messages == [tool])
#expect(room.messages.count == 2)
}
@Test func cursorAndMalformedFrames() throws {
#expect(try AgentCursor.normalize("00012") == "12")
#expect(try AgentCursor.advance("9", "10") == "10")
+5 -2
View File
@@ -26,8 +26,11 @@ Multiple messages can be queued while the Agent works. The local outbox is saved
atomically under Application Support before submission and is scoped to the API
base URL. An interrupted send stays on its original bubble with **Retry**, retaining
its request ID and timestamp across relaunch. A canonical server echo confirms it
even if the HTTP acknowledgement is lost. Tool cards expand to show input, result,
and error details. Attachments and model selection belong to ordinary chats.
even if the HTTP acknowledgement is lost. Raw tool calls and results are hidden
in the Agent transcript, including loaded history and live updates. History windows,
date separators and scroll anchors use visible messages; raw merged history and
cursors remain intact. Send failures, room errors and activity indicators remain
visible. Attachments, model selection and tool cards belong to ordinary chats.
Agent contracts remain in `../docs/api/rest.md` and
`../docs/api/streaming-chat.md`; this client requires no backend deployment.
+19 -22
View File
@@ -55,11 +55,11 @@ final class AgentUITests: XCTestCase {
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: echo wins fixture")).firstMatch.waitForExistence(timeout: 12))
XCTAssertFalse(app.buttons["agent-retry"].exists)
composer.tap(); app.swipeDown()
let failures = app.buttons.matching(identifier: "agent-tool-fixture_error")
failures.element(boundBy: failures.count - 1).tap()
XCTAssertTrue(app.staticTexts["agent-tool-input"].waitForExistence(timeout: 3))
XCTAssertTrue(app.staticTexts["agent-tool-error"].exists)
capture("agent-tool-error")
XCTAssertFalse(app.buttons["agent-tool-fixture_search"].exists)
XCTAssertFalse(app.buttons["agent-tool-fixture_error"].exists)
XCTAssertFalse(app.staticTexts["Inspecting the fixture"].exists)
XCTAssertFalse(app.staticTexts["Fixture unavailable"].exists)
capture("agent-hidden-tools")
let state = try await control(["action": "state"])
XCTAssertEqual((state["requests"] as? [String: Any])?.count, 4)
}
@@ -71,25 +71,22 @@ final class AgentUITests: XCTestCase {
capture("agent-reduce-motion")
}
func testExpandedToolCollapseToShortHistory() async throws {
func testToolResultsStayHiddenLiveAndAfterReload() async throws {
try await control(["action": "long-tool"])
let tool = app.buttons["agent-tool-fixture_long"]
XCTAssertTrue(tool.waitForExistence(timeout: 5))
tool.tap()
XCTAssertTrue(app.staticTexts["agent-tool-result"].waitForExistence(timeout: 3))
let scroll = app.scrollViews.firstMatch
for _ in 0..<12 {
if tool.isHittable { break }
scroll.swipeDown()
}
XCTAssertTrue(tool.isHittable)
tool.tap()
XCTAssertEqual(tool.value as? String, "Collapsed")
try await control(["action": "reply", "content": "Reply after collapsing a tall tool"])
XCTAssertTrue(app.staticTexts["Reply after collapsing a tall tool"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["Reply after collapsing a tall tool"].isHittable)
try await control(["action": "reply", "content": "Reply after a hidden tool result"])
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].isHittable)
XCTAssertFalse(app.buttons["agent-tool-fixture_long"].exists)
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
XCTAssertFalse(app.staticTexts["agent-tool-result"].exists)
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
capture("agent-collapsed-tool-short-history")
capture("agent-hidden-tool-live")
app.terminate(); app.launch(); openAgent()
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].waitForExistence(timeout: 5))
XCTAssertFalse(app.buttons["agent-tool-fixture_long"].exists)
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
XCTAssertFalse(app.staticTexts["agent-tool-result"].exists)
capture("agent-hidden-tool-history")
}
func testTabletSidebarAndInactiveChat() async throws {