ios: isolate Agent scrolling and activity rendering
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
#if DEBUG
|
||||||
|
/// Opt-in operation counters for the simulator presentation benchmark. These
|
||||||
|
/// measure work, not frames; CADisplayLink sampling lives in the test target.
|
||||||
|
@MainActor
|
||||||
|
enum AgentRenderDiagnostics {
|
||||||
|
static var enabled = false
|
||||||
|
static var counts: [String: Int] = [:]
|
||||||
|
|
||||||
|
static func record(_ operation: String) {
|
||||||
|
guard enabled else { return }
|
||||||
|
counts[operation, default: 0] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -74,6 +74,9 @@ final class SybilAgentRoom {
|
|||||||
var isBusy: Bool { snapshot.room.activity.isBusy }
|
var isBusy: Bool { snapshot.room.activity.isBusy }
|
||||||
var unreadCount: Int { snapshot.room.unreadCount }
|
var unreadCount: Int { snapshot.room.unreadCount }
|
||||||
var messages: [Message] {
|
var messages: [Message] {
|
||||||
|
#if DEBUG
|
||||||
|
AgentRenderDiagnostics.record("messageProjection")
|
||||||
|
#endif
|
||||||
let committed = Set(snapshot.room.messages.map(\.id))
|
let committed = Set(snapshot.room.messages.map(\.id))
|
||||||
let queued = snapshot.room.activity.queuedMessages.filter { !committed.contains($0.id) }
|
let queued = snapshot.room.activity.queuedMessages.filter { !committed.contains($0.id) }
|
||||||
var messages = AgentRoomState.merge(snapshot.room.messages, queued)
|
var messages = AgentRoomState.merge(snapshot.room.messages, queued)
|
||||||
@@ -337,6 +340,9 @@ final class SybilAgentRoom {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func flush() {
|
func flush() {
|
||||||
|
#if DEBUG
|
||||||
|
AgentRenderDiagnostics.record("snapshotPublish")
|
||||||
|
#endif
|
||||||
batchTask?.cancel(); batchTask = nil
|
batchTask?.cancel(); batchTask = nil
|
||||||
typingTask?.cancel(); typingTask = nil
|
typingTask?.cancel(); typingTask = nil
|
||||||
snapshot = Snapshot(room: pending, isTyping: presenter.isTyping(now: Date()), entranceIDs: pendingEntrances, revision: snapshot.revision + 1)
|
snapshot = Snapshot(room: pending, isTyping: presenter.isTyping(now: Date()), entranceIDs: pendingEntrances, revision: snapshot.revision + 1)
|
||||||
|
|||||||
@@ -11,22 +11,25 @@ struct SybilAgentView: View {
|
|||||||
@State private var historyLimit = 100
|
@State private var historyLimit = 100
|
||||||
@State private var historyStartID: String?
|
@State private var historyStartID: String?
|
||||||
@State private var scrollPosition = ScrollPosition()
|
@State private var scrollPosition = ScrollPosition()
|
||||||
@State private var scrollMetrics = AgentScrollMetrics()
|
@State private var scrollTracking = AgentScrollTracking()
|
||||||
@State private var rowFrames: [String: CGRect] = [:]
|
@State private var atBottom = false
|
||||||
@State private var restoreAnchor: AgentReadingAnchor?
|
@State private var restoreAnchor: AgentReadingAnchor?
|
||||||
@State private var appeared = false
|
@State private var appeared = false
|
||||||
private let bottomID = "agent-bottom"
|
private let bottomID = "agent-bottom"
|
||||||
|
|
||||||
private var readKey: String {
|
private var readKey: String {
|
||||||
"\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
"\(room.snapshot.room.cursor):\(atLatest):\(atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
||||||
}
|
}
|
||||||
private var visibleReadingAnchor: AgentReadingAnchor? {
|
private var visibleReadingAnchor: AgentReadingAnchor? {
|
||||||
rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollMetrics.height }
|
scrollTracking.rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollTracking.metrics.height }
|
||||||
.min { $0.value.minY < $1.value.minY }
|
.min { $0.value.minY < $1.value.minY }
|
||||||
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
|
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
#if DEBUG
|
||||||
|
let _ = AgentRenderDiagnostics.record("agentBody")
|
||||||
|
#endif
|
||||||
let rendered = room.snapshot
|
let rendered = room.snapshot
|
||||||
let transcript = AgentTranscript(messages: room.messages, startID: historyStartID, limit: historyLimit)
|
let transcript = AgentTranscript(messages: room.messages, startID: historyStartID, limit: historyLimit)
|
||||||
let allMessages = transcript.messages
|
let allMessages = transcript.messages
|
||||||
@@ -51,21 +54,11 @@ struct SybilAgentView: View {
|
|||||||
}
|
}
|
||||||
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
|
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
|
||||||
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
|
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
|
||||||
VStack(spacing: 14) {
|
AgentTranscriptRow(message: message, room: room,
|
||||||
if index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt) {
|
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
|
||||||
Text(message.createdAt.formatted(date: .abbreviated, time: .omitted))
|
.equatable()
|
||||||
.font(.sybil(.caption2))
|
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.id(message.id)
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
}
|
|
||||||
AgentMessageRow(message: message, room: room, retry: { room.retry(message.id) })
|
|
||||||
.background(GeometryReader { geometry in
|
|
||||||
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
|
||||||
.id(message.id)
|
|
||||||
}
|
}
|
||||||
if rendered.isTyping {
|
if rendered.isTyping {
|
||||||
HStack(spacing: 9) {
|
HStack(spacing: 9) {
|
||||||
@@ -103,7 +96,11 @@ struct SybilAgentView: View {
|
|||||||
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
|
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
|
||||||
visibleBottom: geometry.visibleRect.maxY)
|
visibleBottom: geometry.visibleRect.maxY)
|
||||||
} action: { previous, next in
|
} action: { previous, next in
|
||||||
scrollMetrics = next
|
#if DEBUG
|
||||||
|
AgentRenderDiagnostics.record("scrollGeometry")
|
||||||
|
#endif
|
||||||
|
scrollTracking.metrics = next
|
||||||
|
if atBottom != next.atBottom { atBottom = next.atBottom }
|
||||||
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom }
|
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom }
|
||||||
if atLatest, !isScrolling, restoreAnchor == nil,
|
if atLatest, !isScrolling, restoreAnchor == nil,
|
||||||
previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset {
|
previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset {
|
||||||
@@ -111,23 +108,26 @@ struct SybilAgentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onPreferenceChange(AgentRowFrames.self) { frames in
|
.onPreferenceChange(AgentRowFrames.self) { frames in
|
||||||
|
#if DEBUG
|
||||||
|
AgentRenderDiagnostics.record("rowPreferences")
|
||||||
|
#endif
|
||||||
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil)
|
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil)
|
||||||
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
|
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
|
||||||
// Restore the exact pixel offset after insertion or text
|
// Restore the exact pixel offset after insertion or text
|
||||||
// reflow. Normalize the saved frames to
|
// reflow. Normalize the saved frames to
|
||||||
// the requested offset so the next layout cannot undo it.
|
// the requested offset so the next layout cannot undo it.
|
||||||
let requested = scrollMetrics.offset + frame.minY - anchor.offset
|
let requested = scrollTracking.metrics.offset + frame.minY - anchor.offset
|
||||||
let target = min(max(requested, -scrollMetrics.topInset), scrollMetrics.maximumOffset)
|
let target = min(max(requested, -scrollTracking.metrics.topInset), scrollTracking.metrics.maximumOffset)
|
||||||
let appliedShift = target - scrollMetrics.offset
|
let appliedShift = target - scrollTracking.metrics.offset
|
||||||
rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
|
scrollTracking.rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
|
||||||
if abs(appliedShift) > 1 {
|
if abs(appliedShift) > 1 {
|
||||||
// Geometry reports the raw offset; ScrollPosition uses
|
// Geometry reports the raw offset; ScrollPosition uses
|
||||||
// zero at the top inset, including the safe area.
|
// zero at the top inset, including the safe area.
|
||||||
scrollPosition.scrollTo(y: target + scrollMetrics.topInset)
|
scrollPosition.scrollTo(y: target + scrollTracking.metrics.topInset)
|
||||||
}
|
}
|
||||||
restoreAnchor = nil
|
restoreAnchor = nil
|
||||||
} else {
|
} else {
|
||||||
rowFrames = frames
|
scrollTracking.rowFrames = frames
|
||||||
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
|
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,9 +170,9 @@ struct SybilAgentView: View {
|
|||||||
// This cursor belongs to the rendered snapshot. New network input cancels
|
// This cursor belongs to the rendered snapshot. New network input cancels
|
||||||
// the task, and cannot be acknowledged while still entering the layout.
|
// the task, and cannot be acknowledged while still entering the layout.
|
||||||
let cursor = rendered.room.cursor
|
let cursor = rendered.room.cursor
|
||||||
guard atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
|
guard atLatest, scrollTracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
|
||||||
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
|
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
|
||||||
guard !Task.isCancelled, atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
|
guard !Task.isCancelled, atLatest, scrollTracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
|
||||||
room.markRead(renderedCursor: cursor)
|
room.markRead(renderedCursor: cursor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,10 +236,41 @@ struct AgentAvatar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Activity/read publications still update the surrounding view. Completed rows
|
||||||
|
// depend only on their message values and date heading, so progress changes
|
||||||
|
// must not rebuild the eager history (whose exact heights preserve anchors).
|
||||||
|
private struct AgentTranscriptRow: View, Equatable {
|
||||||
|
var message: Message
|
||||||
|
var room: SybilAgentRoom
|
||||||
|
var startsDay: Bool
|
||||||
|
|
||||||
|
nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
|
||||||
|
lhs.message == rhs.message && lhs.room === rhs.room && lhs.startsDay == rhs.startsDay
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if DEBUG
|
||||||
|
let _ = AgentRenderDiagnostics.record("transcriptRow")
|
||||||
|
#endif
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
if startsDay {
|
||||||
|
Text(message.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
AgentMessageRow(message: message, room: room)
|
||||||
|
.background(GeometryReader { geometry in
|
||||||
|
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct AgentMessageRow: View {
|
private struct AgentMessageRow: View {
|
||||||
var message: Message
|
var message: Message
|
||||||
var room: SybilAgentRoom
|
var room: SybilAgentRoom
|
||||||
var retry: () -> Void
|
|
||||||
private var own: Bool { message.role == .user }
|
private var own: Bool { message.role == .user }
|
||||||
private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" }
|
private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" }
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -257,7 +288,7 @@ private struct AgentMessageRow: View {
|
|||||||
if own && status == "queued" { Label("Queued", systemImage: "clock") }
|
if own && status == "queued" { Label("Queued", systemImage: "clock") }
|
||||||
if own && status == "failed" {
|
if own && status == "failed" {
|
||||||
Text("Not confirmed").foregroundStyle(SybilTheme.danger)
|
Text("Not confirmed").foregroundStyle(SybilTheme.danger)
|
||||||
Button("Retry", action: retry).accessibilityIdentifier("agent-retry")
|
Button("Retry") { room.retry(message.id) }.accessibilityIdentifier("agent-retry")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
|
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
|
||||||
@@ -297,6 +328,14 @@ private struct AgentEntrance: ViewModifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pixel geometry is imperative anchor bookkeeping, not rendered content. Keeping
|
||||||
|
// it outside observation avoids rebuilding the entire transcript on every scroll
|
||||||
|
// frame. Only the at-bottom boundary above invalidates the read acknowledgement.
|
||||||
|
private final class AgentScrollTracking {
|
||||||
|
var metrics = AgentScrollMetrics()
|
||||||
|
var rowFrames: [String: CGRect] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
private struct AgentScrollMetrics: Equatable {
|
private struct AgentScrollMetrics: Equatable {
|
||||||
var offset: CGFloat = 0
|
var offset: CGFloat = 0
|
||||||
var height: CGFloat = 0
|
var height: CGFloat = 0
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ struct SybilMessageText: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
#if DEBUG
|
||||||
|
let _ = AgentRenderDiagnostics.record("messageTextBuild")
|
||||||
|
#endif
|
||||||
Markdown(format == .literal ? Self.literalContent(content) : MarkdownContent(content))
|
Markdown(format == .literal ? Self.literalContent(content) : MarkdownContent(content))
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
.foregroundStyle(format == .literal ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
.foregroundStyle(format == .literal ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Agent scrolling benchmark
|
||||||
|
|
||||||
|
Run the production Agent view in an app-hosted XCTest on the iPhone 16e simulator:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
just generate
|
||||||
|
xcodebuild test -scheme SybilPresentation \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
|
||||||
|
-derivedDataPath build/DerivedData -parallel-testing-enabled NO \
|
||||||
|
-only-testing:SybilPresentationTests/AgentPerformanceTests \
|
||||||
|
-resultBundlePath build/agent-performance.xcresult
|
||||||
|
```
|
||||||
|
|
||||||
|
`AgentPerformanceTests` seeds a room through its normal event reducer with 160
|
||||||
|
local messages (100 initially displayed). User text is literal; assistant text
|
||||||
|
contains paragraphs, lists, links, emphasis and code. The real Agent view,
|
||||||
|
composer, avatar, entrance layout and thinking spinner remain enabled. No server,
|
||||||
|
production credentials, notification delivery or network images are needed.
|
||||||
|
|
||||||
|
Three approximately 12-second phases run in order: idle scrolling, thinking at
|
||||||
|
the bottom, and thinking while scrolling. The test explicitly resets the offset
|
||||||
|
to the bottom before the stationary thinking phase (including in the baseline).
|
||||||
|
Thinking activity changes queue depth, worker count and tool progress every
|
||||||
|
500 ms through the normal 100 ms publication
|
||||||
|
batch. The display link sweeps a 2400-point range at 600 points/second, reversing
|
||||||
|
every four seconds. Use the **same test source, simulator, build configuration
|
||||||
|
and host load** for before/after runs. Don't run other builds or profilers during
|
||||||
|
a measured run. Warm builds and repeat runs help distinguish stable effects from
|
||||||
|
host noise.
|
||||||
|
|
||||||
|
The geometry assertions use iPhone 16e, iOS 26.5, and the default text size. They
|
||||||
|
retain the unoptimized transcript heights (39181.333 points idle, 39223.333 while
|
||||||
|
thinking), so an isolation wrapper cannot silently change row spacing. Rebaseline
|
||||||
|
these explicitly when changing simulator/font configuration or the fixture.
|
||||||
|
|
||||||
|
The log prints `AGENT_PERFORMANCE_JSON`; the result bundle retains the JSON as an
|
||||||
|
attachment. Delivery-gap p50/p95/p99/max and counts over 25/50 ms measure actual
|
||||||
|
main-run-loop responsiveness. `missedBudgetIntervals` estimates gaps relative to
|
||||||
|
the reported display-link budget. These are **not compositor FPS, GPU hitches,
|
||||||
|
touch latency or physical-device results**. Scrolling is programmatic; native
|
||||||
|
swipe/anchor/read behavior needs the local-fixture UI regression tests as well.
|
||||||
|
The hosted view excludes the surrounding app shell. Physical iPhone scrolling,
|
||||||
|
streaming and thinking animation remain a required follow-up.
|
||||||
|
|
||||||
|
Opt-in `AgentRenderDiagnostics` counters are compiled only in Debug and are off
|
||||||
|
outside this test. They explain work (Agent body evaluations, message projection,
|
||||||
|
message text construction, isolated row evaluations, geometry callbacks and
|
||||||
|
snapshot publications). A drop in these counts alone does not establish improved
|
||||||
|
frame responsiveness.
|
||||||
|
|
||||||
|
## Reference result
|
||||||
|
|
||||||
|
On monsoon, Xcode 26.5, Debug, iPhone 16e / iOS 26.5, clean repeat runs against
|
||||||
|
`f534f76` plus counters and against the geometry/row isolation fix gave:
|
||||||
|
|
||||||
|
| Phase | Before p95 / p99 (ms) | After p95 / p99 (ms) | Gaps >50 ms, before / after |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| Idle scroll | 50.01 / 50.02 | 16.68 / 16.71 | 128 / 0 |
|
||||||
|
| Thinking stationary | 33.33 / 81.92 | 28.91 / 38.35 | 22 / 2 |
|
||||||
|
| Thinking scroll | 88.78 / 90.89 | 19.41 / 30.42 | 123 / 1 |
|
||||||
|
|
||||||
|
Each phase lasted approximately 12 seconds. Content heights matched exactly.
|
||||||
|
Busy-scroll Agent body/projection evaluations fell from 261 to 27; unchanged
|
||||||
|
text constructions were zero in both versions. Geometry isolation alone removed
|
||||||
|
the idle-scroll stalls; individual row isolation reduced activity-related gaps.
|
||||||
|
These results support those two boundaries, without a Markdown cache or lazy
|
||||||
|
layout change. Occasional simulator gaps remain; this does not replace device
|
||||||
|
validation.
|
||||||
@@ -138,6 +138,18 @@ async def route(request):
|
|||||||
elif action == 'busy':
|
elif action == 'busy':
|
||||||
busy = body.get('value', True)
|
busy = body.get('value', True)
|
||||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||||
|
elif action == 'progress':
|
||||||
|
busy = True
|
||||||
|
progress = dict(activity(), queued=body['queued'], backgroundWorkers=body['workers'],
|
||||||
|
tools=[dict(id='local-review', name='review_transcript', status='running',
|
||||||
|
output=f"Checked {body['step']} local entries")])
|
||||||
|
await broadcast(dict(type='activity', conversationId='fixture-root', activity=progress))
|
||||||
|
elif action == 'edit-last':
|
||||||
|
cursor += 1
|
||||||
|
row = dict(messages[-1], content=body['content'],
|
||||||
|
metadata=dict(messages[-1]['metadata'], entryId=cursor))
|
||||||
|
messages[-1] = row
|
||||||
|
await broadcast(dict(type='messages', **page([row])))
|
||||||
elif action == 'read':
|
elif action == 'read':
|
||||||
read_cursor = cursor
|
read_cursor = cursor
|
||||||
await broadcast(dict(type='read_state', sourceId=PUSH_SOURCE, cursor=str(read_cursor), updatedAt=stamp()))
|
await broadcast(dict(type='read_state', sourceId=PUSH_SOURCE, cursor=str(read_cursor), updatedAt=stamp()))
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import XCTest
|
||||||
|
import SwiftUI
|
||||||
|
@testable import Sybil
|
||||||
|
|
||||||
|
/// Run on an iPhone simulator, with the real Agent view and deterministic local
|
||||||
|
/// messages. Display-link delivery gaps measure main-run-loop responsiveness,
|
||||||
|
/// not GPU presentation, touch latency, or physical-device frame rate.
|
||||||
|
@MainActor
|
||||||
|
final class AgentPerformanceTests: XCTestCase {
|
||||||
|
func testThinkingScrollResponsiveness() async throws {
|
||||||
|
let room = SybilAgentRoom()
|
||||||
|
var rows: [Message] = []
|
||||||
|
for index in 1...160 { rows.append(Self.message(index)) }
|
||||||
|
try room.receive(.messages(AgentPage(conversationId: "performance", messages: rows, cursor: "160", beforeCursor: nil, hasMore: false)), replay: true)
|
||||||
|
room.flush()
|
||||||
|
let host = UIHostingController(rootView: SybilAgentView(room: room, topInset: 64)
|
||||||
|
.background(SybilTheme.background).environment(\.colorScheme, .dark))
|
||||||
|
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||||
|
let window = UIWindow(windowScene: scene)
|
||||||
|
window.rootViewController = host
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
defer {
|
||||||
|
AgentRenderDiagnostics.enabled = false
|
||||||
|
window.isHidden = true
|
||||||
|
window.rootViewController = nil
|
||||||
|
room.stop()
|
||||||
|
}
|
||||||
|
host.view.frame = window.bounds
|
||||||
|
host.view.layoutIfNeeded()
|
||||||
|
try await Task.sleep(for: .seconds(2))
|
||||||
|
let scroll = try XCTUnwrap(findScroll(host.view))
|
||||||
|
XCTAssertGreaterThan(scroll.contentSize.height, 10_000)
|
||||||
|
// Move away from the pinned bottom before the controlled sweeps.
|
||||||
|
scroll.setContentOffset(CGPoint(x: 0, y: max(0, scroll.contentSize.height - scroll.bounds.height - 1200)), animated: false)
|
||||||
|
try await Task.sleep(for: .seconds(1))
|
||||||
|
AgentRenderDiagnostics.enabled = true
|
||||||
|
var reports: [[String: Any]] = []
|
||||||
|
for phase in ["idle-scroll", "thinking-stationary", "thinking-scroll"] {
|
||||||
|
if phase == "thinking-stationary" {
|
||||||
|
try room.receive(.activity("performance", busyActivity(step: 0)))
|
||||||
|
room.flush()
|
||||||
|
scroll.setContentOffset(CGPoint(x: 0, y: scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom), animated: false)
|
||||||
|
try await Task.sleep(for: .seconds(1))
|
||||||
|
XCTAssertTrue(room.snapshot.isTyping)
|
||||||
|
}
|
||||||
|
AgentRenderDiagnostics.counts = [:]
|
||||||
|
let sampler = AgentFrameSampler(scroll: phase == "thinking-stationary" ? nil : scroll)
|
||||||
|
sampler.start()
|
||||||
|
// Changing queue/worker/tool progress every 500 ms exercises real
|
||||||
|
// activity observation while the typing spinner remains active. No IO.
|
||||||
|
for step in 1...24 {
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
|
if phase != "idle-scroll" {
|
||||||
|
try room.receive(.activity("performance", busyActivity(step: step)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var report = sampler.stop()
|
||||||
|
report["phase"] = phase
|
||||||
|
report["procedure"] = "mixed160-changing-activity-v1"
|
||||||
|
report["operations"] = AgentRenderDiagnostics.counts
|
||||||
|
report["contentHeight"] = scroll.contentSize.height
|
||||||
|
// iPhone 16e / iOS 26.5 / default text size reference geometry from
|
||||||
|
// the unoptimized view. A faster result must not shrink the content.
|
||||||
|
XCTAssertEqual(scroll.contentSize.height,
|
||||||
|
phase == "idle-scroll" ? 39_181.333333 : 39_223.333333,
|
||||||
|
accuracy: 1)
|
||||||
|
reports.append(report)
|
||||||
|
}
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: reports, options: [.sortedKeys])
|
||||||
|
print("AGENT_PERFORMANCE_JSON " + String(decoding: data, as: UTF8.self))
|
||||||
|
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
|
||||||
|
attachment.name = "agent-performance"; attachment.lifetime = .keepAlways; add(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func message(_ index: Int) -> Message {
|
||||||
|
let own = index % 3 == 0
|
||||||
|
let content = own
|
||||||
|
? "Can you review step \(index)? Keep the scroll position and the **literal input** intact.\n\nInclude the next checks."
|
||||||
|
: "### Investigation \(index)\n\nThe worker checked the local transcript and found a repeatable result. This paragraph contains **emphasis**, `inline code`, and a [reference](https://example.com).\n\n- Preserve the reading position while new messages arrive.\n- Keep attachments, queued messages, and status updates visible.\n- Verify the result on the simulator before publishing.\n\n```swift\nlet item = messages[\(index)]\nrender(item)\n```\n\nThe next step is to compare the measurements and report any remaining limitations."
|
||||||
|
return Message(id: "perf-\(index)", createdAt: Date(timeIntervalSince1970: 1_700_000_000 + Double(index * 60)),
|
||||||
|
role: own ? .user : .assistant, content: content,
|
||||||
|
metadata: .object(["kind": .string("message"), "entryId": .string(String(index))]))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func busyActivity(step: Int) -> AgentActivity {
|
||||||
|
AgentActivity(state: "thinking", isThinking: true, queued: 1 + step % 3,
|
||||||
|
backgroundWorkers: 1 + step % 2,
|
||||||
|
tools: [.init(id: "local-review", name: "review_transcript", status: "running",
|
||||||
|
output: "Checked \(step * 7) of 200 local entries; preparing batch \(step + 1).")])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findScroll(_ view: UIView) -> UIScrollView? {
|
||||||
|
if let scroll = view as? UIScrollView, scroll.contentSize.height > 10_000 { return scroll }
|
||||||
|
return view.subviews.lazy.compactMap { self.findScroll($0) }.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class AgentFrameSampler: NSObject {
|
||||||
|
private weak var scroll: UIScrollView?
|
||||||
|
private var link: CADisplayLink?
|
||||||
|
private var previous: CFTimeInterval?
|
||||||
|
private var started: CFTimeInterval = 0
|
||||||
|
private var gaps: [Double] = []
|
||||||
|
private var budgets: [Double] = []
|
||||||
|
private var origin: CGFloat = 0
|
||||||
|
private var scrollDistance: CGFloat = 0
|
||||||
|
init(scroll: UIScrollView?) { self.scroll = scroll }
|
||||||
|
func start() {
|
||||||
|
origin = scroll?.contentOffset.y ?? 0
|
||||||
|
started = CACurrentMediaTime()
|
||||||
|
let link = CADisplayLink(target: self, selector: #selector(frame(_:)))
|
||||||
|
link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 60, preferred: 60)
|
||||||
|
link.add(to: .main, forMode: .common)
|
||||||
|
self.link = link
|
||||||
|
}
|
||||||
|
@objc private func frame(_ link: CADisplayLink) {
|
||||||
|
let now = CACurrentMediaTime()
|
||||||
|
if let previous { gaps.append((now - previous) * 1000); budgets.append((link.targetTimestamp - link.timestamp) * 1000) }
|
||||||
|
previous = now
|
||||||
|
if let scroll {
|
||||||
|
// Constant-speed triangular sweep over 2400 points, one cycle / 8 s.
|
||||||
|
let phase = (now - started).truncatingRemainder(dividingBy: 8) / 4
|
||||||
|
let delta = CGFloat(phase <= 1 ? phase : 2 - phase) * 2400
|
||||||
|
let target = max(-scroll.adjustedContentInset.top, origin - delta)
|
||||||
|
scrollDistance += abs(target - scroll.contentOffset.y)
|
||||||
|
scroll.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func stop() -> [String: Any] {
|
||||||
|
link?.invalidate(); link = nil
|
||||||
|
let sorted = gaps.sorted()
|
||||||
|
let budget = budgets.sorted()[budgets.count / 2]
|
||||||
|
func percentile(_ value: Double) -> Double { sorted[min(sorted.count - 1, Int(Double(sorted.count - 1) * value))] }
|
||||||
|
return ["seconds": CACurrentMediaTime() - started, "callbacks": gaps.count,
|
||||||
|
"medianBudgetMS": budget, "p50MS": percentile(0.5), "p95MS": percentile(0.95),
|
||||||
|
"p99MS": percentile(0.99), "maxMS": sorted.last ?? 0,
|
||||||
|
"over25MS": gaps.filter { $0 > 25 }.count, "over50MS": gaps.filter { $0 > 50 }.count,
|
||||||
|
"missedBudgetIntervals": gaps.reduce(0) { $0 + max(0, Int(($1 / budget).rounded()) - 1) },
|
||||||
|
"scrollDistance": scrollDistance]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -241,6 +241,24 @@ final class AgentUITests: XCTestCase {
|
|||||||
let state = try await control(["action": "state"])
|
let state = try await control(["action": "state"])
|
||||||
XCTAssertEqual((state["requests"] as? [String: Any])?.count, 4)
|
XCTAssertEqual((state["requests"] as? [String: Any])?.count, 4)
|
||||||
}
|
}
|
||||||
|
func testBusyProgressAndSameIDMessageUpdate() async throws {
|
||||||
|
try await control(["action": "progress", "queued": 2, "workers": 1, "step": 7])
|
||||||
|
XCTAssertTrue(app.staticTexts["Sybil is thinking…"].waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(app.staticTexts["2 queued"].waitForExistence(timeout: 5))
|
||||||
|
try await control(["action": "progress", "queued": 3, "workers": 2, "step": 14])
|
||||||
|
XCTAssertTrue(app.staticTexts["3 queued"].waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(app.staticTexts["2 working"].exists)
|
||||||
|
// Same stable ID, changed content: the transcript boundary must update.
|
||||||
|
try await control(["action": "edit-last", "content": "**Revised response**\n\n[Updated reference](http://127.0.0.1:8799)"])
|
||||||
|
XCTAssertTrue(app.staticTexts["Revised response"].waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(app.links["Updated reference"].exists)
|
||||||
|
XCTAssertFalse(app.staticTexts["History message 3: local simulator fixture."].exists)
|
||||||
|
XCTAssertTrue(app.staticTexts["Sybil is thinking…"].exists)
|
||||||
|
try await control(["action": "busy", "value": false])
|
||||||
|
let stopped = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: app.staticTexts["Sybil is thinking…"])
|
||||||
|
await fulfillment(of: [stopped], timeout: 5)
|
||||||
|
}
|
||||||
|
|
||||||
func testReduceMotionSend() async throws {
|
func testReduceMotionSend() async throws {
|
||||||
try XCTSkipUnless(UIAccessibility.isReduceMotionEnabled, "Run with Simulator Reduce Motion enabled")
|
try XCTSkipUnless(UIAccessibility.isReduceMotionEnabled, "Run with Simulator Reduce Motion enabled")
|
||||||
send("reduce motion fixture")
|
send("reduce motion fixture")
|
||||||
|
|||||||
Reference in New Issue
Block a user