2026-10-14 23:14:53 -07:00
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 {
2026-10-16 16:54:46 -07:00
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\n Its 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 ) " )
}
2026-10-14 23:14:53 -07:00
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 )
}
2026-10-16 11:51:07 -07:00
/// 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 )
}
2026-10-14 23:14:53 -07:00
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\n Include the next checks."
: "### Investigation \( index ) \n\n The 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 \n let item = messages[ \( index ) ] \n render(item) \n ``` \n\n The 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 ]
}
}