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 testEntranceGrowsOccupiedHeight() async throws { XCTAssertFalse(UIAccessibility.isReduceMotionEnabled, "This check must exercise the entrance animation") let room = SybilAgentRoom() let rows = (1...3).map { index in Message(id: "entrance-\(index)", createdAt: Date(timeIntervalSince1970: 1_700_000_000), role: .assistant, content: "Existing neighbor \(index)") } try room.receive(.messages(AgentPage(conversationId: "entrance", messages: rows, cursor: "3", beforeCursor: nil, hasMore: false)), replay: true) room.flush() let host = UIHostingController(rootView: SybilAgentView(room: room)) let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) let window = UIWindow(windowScene: scene) window.rootViewController = host window.makeKeyAndVisible() defer { window.isHidden = true; window.rootViewController = nil; room.stop() } host.view.frame = window.bounds host.view.layoutIfNeeded() try await Task.sleep(for: .milliseconds(500)) func transcript(in view: UIView) -> UIScrollView? { if let scroll = view as? UIScrollView, scroll.contentSize.height > 100 { return scroll } return view.subviews.lazy.compactMap { transcript(in: $0) }.first } let scroll = try XCTUnwrap(transcript(in: host.view)) let before = scroll.contentSize.height let reply = Message(id: "entrance-4", createdAt: rows[0].createdAt, role: .assistant, content: "Incoming reply\n\nIts occupied space should grow with the bubble.") try room.receive(.messages(AgentPage(conversationId: "entrance", messages: [reply], cursor: "4", beforeCursor: nil, hasMore: false))) room.flush() var heights: [CGFloat] = [] for _ in 0..<32 { try await Task.sleep(for: .milliseconds(16)) heights.append(scroll.contentSize.height) } let after = try XCTUnwrap(heights.last) // All four short rows fit onscreen, so these are measured heights, not // estimates for unmaterialized lazy history. No frame-rate claim. XCTAssertLessThan(after, scroll.bounds.height) XCTAssertGreaterThan(after - before, 50) let intermediate = Set(heights.filter { $0 > before + 2 && $0 < after - 2 }.map { Int($0.rounded()) }) XCTAssertGreaterThanOrEqual(intermediate.count, 3, "Neighbors must get intermediate layout heights, not a full-height insertion") print("AGENT_ENTRANCE_HEIGHT before=\(before) samples=\(heights)") } 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) } /// Exercises UIKit editing/focus and the real message reducer; no synthetic /// view rebuild loop or replacement composer/animation is used. func testInputAndEntranceResponsiveness() async throws { let room = SybilAgentRoom() try room.receive(.messages(AgentPage(conversationId: "performance", messages: (1...160).map(Self.message), 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 { host.view.endEditing(true) 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)) let input = try XCTUnwrap(findInput(host.view)) AgentRenderDiagnostics.enabled = true var reports: [[String: Any]] = [] func report(_ sampler: AgentFrameSampler, phase: String) { var value = sampler.stop() value["phase"] = phase value["procedure"] = "mixed160-input-entrance-v1" value["operations"] = AgentRenderDiagnostics.counts value["contentHeight"] = scroll.contentSize.height value["viewportHeight"] = scroll.bounds.height reports.append(value) } AgentRenderDiagnostics.counts = [:] let entrance = AgentFrameSampler(scroll: nil) entrance.start() for index in 161...172 { try room.receive(.messages(AgentPage(conversationId: "performance", messages: [Self.message(index)], cursor: String(index), beforeCursor: nil, hasMore: false))) // Use the production 100 ms publication batch and 240 ms entrance. try await Task.sleep(for: .milliseconds(550)) assertAtBottom(scroll) } XCTAssertLessThanOrEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 24, "Entrance frames must not repeatedly project the history") report(entrance, phase: "message-entrance") XCTAssertEqual(room.snapshot.room.messages.count, 172) assertAtBottom(scroll) capture(host, "input-entrance-latest") let unfocusedHeight = scroll.bounds.height AgentRenderDiagnostics.counts = [:] let keyboard = AgentFrameSampler(scroll: nil) keyboard.start() for _ in 0..<6 { XCTAssertTrue(input.becomeFirstResponder()) try await Task.sleep(for: .milliseconds(600)) XCTAssertLessThan(scroll.bounds.height, unfocusedHeight - 100, "Software keyboard must actually resize the transcript") assertAtBottom(scroll) XCTAssertTrue(input.resignFirstResponder()) try await Task.sleep(for: .milliseconds(600)) } XCTAssertEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 0, "Keyboard focus and geometry must not project the transcript") report(keyboard, phase: "keyboard-presentation") XCTAssertEqual(scroll.bounds.height, unfocusedHeight, accuracy: 1) XCTAssertTrue(input.becomeFirstResponder()) try await Task.sleep(for: .milliseconds(700)) let keyInput = try XCTUnwrap(input as? any UIKeyInput) AgentRenderDiagnostics.counts = [:] let typing = AgentFrameSampler(scroll: nil) typing.start() let text = "Typing into the actual composer should keep the long transcript responsive as this sentence wraps across several lines. " for character in text + text { keyInput.insertText(String(character)) try await Task.sleep(for: .milliseconds(30)) } XCTAssertEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 0, "Draft edits and line wrapping must not project the transcript") assertAtBottom(scroll) report(typing, phase: "composer-typing") XCTAssertEqual(room.composer, text + text) capture(host, "input-multiline-keyboard") let data = try JSONSerialization.data(withJSONObject: reports, options: [.sortedKeys]) print("AGENT_INPUT_PERFORMANCE_JSON " + String(decoding: data, as: UTF8.self)) let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json") attachment.name = "agent-input-performance"; attachment.lifetime = .keepAlways; add(attachment) } private func assertAtBottom(_ scroll: UIScrollView, file: StaticString = #filePath, line: UInt = #line) { XCTAssertEqual(scroll.contentOffset.y + scroll.bounds.height - scroll.adjustedContentInset.bottom, scroll.contentSize.height, accuracy: 25, file: file, line: line) } private func findInput(_ view: UIView) -> UIView? { if view is UITextView || view is UITextField { return view } return view.subviews.lazy.compactMap { self.findInput($0) }.first } private func capture(_ host: UIViewController, _ name: String) { let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true) } let attachment = XCTAttachment(image: image) attachment.name = name; 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] } }