Compare commits

...
Author SHA1 Message Date
buzzert 4cbab487d0 ios: hide raw tool entries in Agent transcript
TestFlight / Build and upload (push) Successful in 1m59s
2026-10-14 15:40:37 -07:00
buzzert 56279fba7f web: hide tool entries in Sybil Agent transcript 2026-10-14 15:29:50 -07:00
buzzert 153aa5d2fa ios: share prompt field across chat and agent views
TestFlight / Build and upload (push) Successful in 1m44s
2026-10-14 15:08:34 -07:00
12 changed files with 402 additions and 290 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) })
}
}
.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))
@@ -194,31 +189,31 @@ struct SybilAgentView: View {
}
}
private var canSendComposer: Bool {
!room.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && room.composer.utf16.count <= 40_000
}
private var composer: some View {
HStack(alignment: .bottom, spacing: 10) {
TextField("Message Sybil…", text: $room.composer, axis: .vertical)
.lineLimit(1...6)
.font(.sybil(.body))
.focused($composerFocused)
SybilPromptField(
text: $room.composer,
focused: $composerFocused,
submit: submitComposer
)
.accessibilityIdentifier("agent-composer")
Button {
let content = room.composer
if room.send(content) { room.composer = ""; atLatest = true }
} label: {
Image(systemName: "arrow.up")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 38, height: 38)
.background(SybilTheme.primaryGradient, in: Circle())
}
.disabled(room.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || room.composer.utf16.count > 40_000)
.accessibilityLabel("Send message")
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
.accessibilityIdentifier("agent-send")
}
.padding(12)
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 22))
.overlay(RoundedRectangle(cornerRadius: 22).stroke(SybilTheme.border))
.padding(.horizontal, 12).padding(.bottom, 10).padding(.top, 6)
.padding(.horizontal, 14)
.padding(.bottom, 12)
.padding(.top, 6)
}
private func submitComposer() {
guard canSendComposer, room.send(room.composer) else { return }
room.composer = ""
atLatest = true
}
}
@@ -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
@@ -0,0 +1,62 @@
import SwiftUI
struct SybilPromptField: View {
var placeholder: String = "Enter Prompt"
@Binding var text: String
@FocusState.Binding var focused: Bool
var selection: Binding<TextSelection?>? = nil
var axis: Axis = .vertical
var submit: () -> Void
var body: some View {
Group {
if let selection {
TextField(placeholder, text: $text, selection: selection, axis: axis)
} else {
TextField(placeholder, text: $text, axis: axis)
}
}
.focused($focused)
.font(.sybil(.body))
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.submitLabel(.send)
.onSubmit(submit)
.submitOnMacReturn(submit)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.composerGradient)
.opacity(0.98)
)
.foregroundStyle(SybilTheme.text)
}
}
struct SybilPromptSendButton: View {
var systemImage: String = "arrow.up"
var canSend: Bool
var submit: () -> Void
var body: some View {
Button(action: submit) {
Image(systemName: systemImage)
.font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(
canSend
? AnyShapeStyle(SybilTheme.primaryGradient)
: AnyShapeStyle(SybilTheme.surface)
)
)
.foregroundStyle(canSend ? SybilTheme.text : SybilTheme.textMuted)
}
.buttonStyle(.plain)
.disabled(!canSend)
.accessibilityLabel("Send message")
}
}
@@ -95,46 +95,19 @@ struct SybilQuickQuestionView: View {
private var composer: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .bottom, spacing: 10) {
TextField(
"Ask anything...",
SybilPromptField(
placeholder: "Ask anything...",
text: Binding(
get: { viewModel.quickQuestionPrompt },
set: { viewModel.updateQuickQuestionPrompt($0) }
),
focused: $promptFocused,
selection: $promptSelection,
axis: .horizontal
axis: .horizontal,
submit: submitQuestion
)
.focused($promptFocused)
.font(.body)
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.submitLabel(.send)
.onSubmit(submitQuestion)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.composerGradient)
.opacity(0.98)
)
.foregroundStyle(SybilTheme.text)
Button(action: submitQuestion) {
Image(systemName: "arrow.up")
.font(.body.weight(.semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(
viewModel.canSendQuickQuestion
? AnyShapeStyle(SybilTheme.primaryGradient)
: AnyShapeStyle(SybilTheme.surfaceStrong.opacity(0.92))
)
)
.foregroundStyle(viewModel.canSendQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
}
.buttonStyle(.plain)
.disabled(!viewModel.canSendQuickQuestion)
SybilPromptSendButton(canSend: viewModel.canSendQuickQuestion, submit: submitQuestion)
.accessibilityLabel("Ask quick question")
}
@@ -613,47 +613,19 @@ struct SybilWorkspaceView: View {
.accessibilityLabel("Attach file")
}
TextField(
viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
SybilPromptField(
placeholder: viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
text: $viewModel.composer,
axis: .vertical
focused: $composerFocused,
submit: submitComposer
)
.focused($composerFocused)
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.submitLabel(.send)
.onSubmit {
submitComposer()
}
.submitOnMacReturn(submitComposer)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.composerGradient)
.opacity(0.98)
)
.foregroundStyle(SybilTheme.text)
Button {
submitComposer()
} label: {
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
.font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(
viewModel.canSendComposer
? AnyShapeStyle(SybilTheme.primaryGradient)
: AnyShapeStyle(SybilTheme.surface)
SybilPromptSendButton(
systemImage: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up",
canSend: viewModel.canSendComposer,
submit: submitComposer
)
)
.foregroundStyle(viewModel.canSendComposer ? SybilTheme.text : SybilTheme.textMuted)
}
.buttonStyle(.plain)
.disabled(!viewModel.canSendComposer)
.accessibilityLabel(viewModel.isSearchMode ? "Search" : "Send message")
}
}
.padding(.horizontal, 14)
@@ -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 {
@@ -194,83 +194,6 @@
color:hsl(189 79% 75%);
}
.agent-room__tool {
margin:0 0 0 35px;
max-width:calc(89% - 35px);
border:1px solid hsl(256 35% 47%/.23);
border-radius:11px;
background:hsl(246 32% 12%/.56);
color:hsl(255 26% 75%);
font-size:11px;
}
.agent-room__tool summary {
display:flex;
align-items:center;
gap:9px;
min-height:37px;
padding:8px 11px;
cursor:pointer;
list-style:none;
}
.agent-room__tool summary::-webkit-details-marker {
display:none;
}
.agent-room__tool-icon {
display:grid;
place-items:center;
color:hsl(267 69% 76%);
}
.agent-room__tool-title {
min-width:0;
flex:1;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
text-transform:capitalize;
}
.agent-room__tool-state {
color:hsl(254 18% 51%);
font-size:9px;
}
.agent-room__tool.is-running .agent-room__tool-state {
color:hsl(184 54% 72%);
}
.agent-room__tool.is-failed .agent-room__tool-state {
color:hsl(351 71% 76%);
}
.agent-room__tool-chevron {
color:hsl(255 20% 52%);
transition:transform .15s;
}
.agent-room__tool[open] .agent-room__tool-chevron {
transform:rotate(180deg);
}
.agent-room__tool-body {
padding:0 11px 11px;
}
.agent-room__tool-body pre {
max-height:210px;
overflow:auto;
white-space:pre-wrap;
overflow-wrap:anywhere;
font:10px/1.6 ui-monospace,monospace;
margin:8px 0 0;
padding:9px;
border-radius:6px;
background:hsl(239 42% 6%/.7);
}
.agent-room__typing,.agent-room__waiting {
display:flex;
align-items:center;
@@ -503,10 +426,6 @@
height:23px;
border-radius:8px;
}
.agent-room__tool {
margin-left:30px;
max-width:calc(96% - 30px);
}
.agent-room__typing,.agent-room__waiting {
margin-left:30px;
font-size:10px;
@@ -1,7 +1,8 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import { ArrowDown, ChevronDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff, Wrench } from "lucide-preact";
import { ArrowDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact";
import type { Message } from "@/lib/api";
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
import { agentTranscriptMessages } from "@/lib/agent-room-presentation";
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { Button } from "@/components/ui/button";
import "./agent-conversation.css";
@@ -46,29 +47,6 @@ function toolName(value: unknown) {
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
}
function AgentToolCard({ message }: { message: Message }) {
const info = metadata(message);
const active = info.status === "initiated";
const failed = info.status === "failed";
const title = toolName(info.toolName || message.name);
const preview = typeof info.resultPreview === "string" ? info.resultPreview : typeof info.error === "string" ? info.error : "";
return (
<details className={`agent-room__tool${active ? " is-running" : ""}${failed ? " is-failed" : ""}`} data-agent-message-id={message.id}>
<summary>
<span className="agent-room__tool-icon"><Wrench size={14} aria-hidden="true" /></span>
<span className="agent-room__tool-title">{title}</span>
<span className="agent-room__tool-state">{active ? "Running" : failed ? "Failed" : "Done"}</span>
<ChevronDown className="agent-room__tool-chevron" size={14} aria-hidden="true" />
</summary>
<div className="agent-room__tool-body">
{info.args && typeof info.args === "object" ? <pre aria-label="Tool input">{JSON.stringify(info.args, null, 2)}</pre> : null}
{preview ? <pre aria-label={failed ? "Tool error" : "Tool result"}>{preview}</pre> : null}
{!info.args && !preview ? <p>{active ? "This tool is still running." : "No additional details."}</p> : null}
</div>
</details>
);
}
function AgentBubble({ message, animate, onRetry }: { message: Message; animate: boolean; onRetry: (id: string) => void }) {
const own = message.role === "user";
const info = metadata(message);
@@ -254,21 +232,22 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
};
const messages = useMemo(() => {
const transcript = agentTranscriptMessages(room.messages);
let previousDay = "";
for (const message of room.messages) {
if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading && message.role !== "tool") {
for (const message of transcript) {
if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) {
enteringMessageIdsRef.current.add(message.id);
}
seenMessageIdsRef.current.add(message.id);
}
if (!room.isLoading) historyReadyRef.current = true;
return room.messages.filter(message => message.role !== "system").map(message => {
return transcript.map(message => {
const date = dateOf(message.createdAt), day = dayKey(date), separator = previousDay !== day;
previousDay = day;
return (
<div className="agent-room__entry" key={message.id} data-agent-entry-id={message.id}>
{separator ? <div className="agent-room__day"><span>{dayLabel(date)}</span></div> : null}
{message.role === "tool" ? <AgentToolCard message={message} /> : <AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} />}
<AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} />
</div>
);
});
+5
View File
@@ -1,6 +1,11 @@
import type { AgentRoomEvent, AgentRoomState } from './agent-room';
import type { Message } from './api';
/** Select transcript rows only; keep tool entries in room state for replay and activity. */
export function agentTranscriptMessages(messages: readonly Message[]): Message[] {
return messages.filter(message => message.role !== 'system' && message.role !== 'tool');
}
type Scheduler = {
now(): number;
schedule(callback: () => void, delay: number): unknown;
+88 -2
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAgentEvent, applyAgentReadState, createAgentRoomState } from '../src/lib/agent-room.ts';
import { createAgentRoomPresenter } from '../src/lib/agent-room-presentation.ts';
import { agentDisplayMessages, agentUnreadCount, applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentRoomState, parseAgentRoomEvent } from '../src/lib/agent-room.ts';
import { agentTranscriptMessages, createAgentRoomPresenter } from '../src/lib/agent-room-presentation.ts';
const message = (entry, role = 'assistant', extra = {}) => ({ id: `entry:${entry}`, createdAt: '2026-10-14T00:00:00.000Z', role,
content: `Message ${entry}`, metadata: { kind: 'message', entryId: entry, status: 'complete' }, ...extra });
@@ -27,6 +27,92 @@ function fixture(initial = createAgentRoomState()) {
return { presenter, advance, frames, last: () => frames.at(-1) };
}
test('loaded history hides tool calls and results without removing records or changing normal messages', () => {
const user = message(1, 'user', { content: 'Explain this tool_call and tool_result JSON.' });
const reply = message(5, 'assistant', { content: '**Result**\n\nThe lookup failed; please try another source.' });
const tools = ['initiated', 'completed', 'failed'].map((status, index) => message(index + 2, 'tool', {
id: `tool:1:lookup-${index}`, name: 'lookup', content: 'Raw tool details',
metadata: { kind: 'tool_call', entryId: index + 2, toolCallId: `lookup-${index}`, status,
args: { query: 'raw input' }, resultPreview: 'raw output', error: status === 'failed' ? 'raw error' : null },
}));
const room = applyAgentPage(createAgentRoomState(), page(5, [message(0, 'system'), user, ...tools, reply]));
const original = structuredClone(room);
const transcript = agentTranscriptMessages(agentDisplayMessages(room));
assert.deepEqual(transcript, [user, reply]);
assert.strictEqual(transcript[0], user);
assert.strictEqual(transcript[1], reply);
assert.deepEqual(room, original, 'presentation must leave tool records and cursor intact');
assert.equal(room.messages.length, 6);
assert.equal(agentUnreadCount(room), 1);
});
test('tool-only history produces no transcript rows regardless of metadata or status', () => {
const messages = [null, {}, [], 'legacy metadata', { kind: 'tool_result' }, { kind: 'tool_call', status: 'failed' }]
.map((metadata, index) => message(index + 1, 'tool', { metadata, createdAt: `2026-10-${10 + index}T00:00:00.000Z` }));
assert.deepEqual(agentTranscriptMessages(Object.freeze(messages)), []);
assert.deepEqual(agentTranscriptMessages([]), []);
});
test('user delivery errors, queued messages, and assistant content remain visible even with tool-like metadata', () => {
const users = ['sending', 'queued', 'failed', 'unconfirmed'].map((status, index) => message(index + 1, 'user', {
metadata: { kind: 'message', status, error: 'Could not send; retry this message.' },
}));
const reply = message(5, 'assistant', { content: 'The tool_result contained an error. **Please retry.**',
metadata: { kind: 'tool_call', status: 'failed', error: 'Keep this assistant message.' },
});
const room = applyAgentEvent(applyAgentPage(createAgentRoomState(), page(5, [users[0], ...users.slice(2), reply])),
activity(false, { queued: 1, queuedMessages: [users[1]] }));
const transcript = agentTranscriptMessages(agentDisplayMessages(room));
assert.deepEqual(transcript, [...users, reply]);
for (const [index, original] of [...users, reply].entries()) assert.strictEqual(transcript[index], original);
});
for (const status of ['completed', 'failed']) {
test(`live ${status} tool updates and replay stay hidden while progress, replies, and cursors survive`, () => {
const f = fixture();
const user = message(1, 'user');
const started = message(2, 'tool', { id: 'tool:1:lookup', name: 'lookup',
metadata: { kind: 'tool_call', toolCallId: 'lookup', toolName: 'lookup', entryId: 2, status: 'initiated', args: { query: 'input' } },
});
const finished = { ...started, content: 'Raw tool output',
metadata: { kind: 'tool_call', toolCallId: 'lookup', entryId: 3, status, resultPreview: 'output', error: status === 'failed' ? 'Lookup failed' : null },
};
const progress = activity(true, { tools: [{ id: 'lookup', name: 'lookup', status: 'running' }], backgroundWorkers: 1 });
const receive = event => f.presenter.push(parseAgentRoomEvent(JSON.stringify(event)));
f.presenter.push(page(1, [user]), { replay: true });
receive(progress); f.advance(250);
for (const event of [page(2, [started]), page(3, [finished]), page(2, [started])]) {
receive(event); f.advance(100);
const { room, isTyping } = f.last();
assert.deepEqual(agentTranscriptMessages(agentDisplayMessages(room)), [user]);
assert.equal(isTyping, true, 'tool visibility must not suppress ongoing progress');
assert.deepEqual(room.activity, progress.activity);
assert.equal(room.messages.length, 2, 'tool records still merge by their stable ID');
}
assert.equal(f.last().room.cursor, '3');
assert.equal(f.last().room.readCursor, '0');
assert.equal(agentUnreadCount(f.last().room), 0);
assert.equal(f.last().room.messages[1].metadata.status, status);
assert.deepEqual(f.last().room.messages[1].metadata.args, { query: 'input' });
assert.equal(f.last().room.messages[1].metadata.resultPreview, 'output');
const reply = message(4, 'assistant', { content: status === 'failed' ? 'The lookup failed. Please try again.' : 'Here is the answer.' });
receive(page(4, [reply])); f.advance(100);
assert.deepEqual(agentTranscriptMessages(agentDisplayMessages(f.last().room)), [user, reply]);
assert.equal(f.last().isTyping, false, 'the answer still clears the old typing indicator');
assert.equal(f.last().room.cursor, '4');
assert.equal(agentUnreadCount(f.last().room), 1);
f.presenter.update(room => applyAgentReadState(room, { cursor: '4', updatedAt: null }));
f.presenter.flush();
assert.equal(agentUnreadCount(f.last().room), 0);
assert.equal(f.last().room.messages.length, 3);
f.presenter.dispose();
});
}
test('a reply and removal of its old thinking indicator publish together even when idle is delayed', () => {
const f = fixture();
f.presenter.push(activity(true)); f.advance(250); assert.equal(f.last().isTyping, true);