ios: isolate Agent scrolling and activity rendering

This commit is contained in:
2026-10-14 23:14:57 -07:00
parent 5833a0868e
commit b9a6f5dd7d
8 changed files with 332 additions and 30 deletions
+68
View File
@@ -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.
+12
View File
@@ -138,6 +138,18 @@ async def route(request):
elif action == 'busy':
busy = body.get('value', True)
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':
read_cursor = cursor
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]
}
}
+18
View File
@@ -241,6 +241,24 @@ final class AgentUITests: XCTestCase {
let state = try await control(["action": "state"])
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 {
try XCTSkipUnless(UIAccessibility.isReduceMotionEnabled, "Run with Simulator Reduce Motion enabled")
send("reduce motion fixture")