Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cbab487d0 | ||
|
|
56279fba7f | ||
|
|
153aa5d2fa |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user