From b9a6f5dd7de22b6fe85fc0c621e5d54fc129a576 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 14 Oct 2026 23:14:53 -0700 Subject: [PATCH] ios: isolate Agent scrolling and activity rendering --- .../Sybil/AgentRenderDiagnostics.swift | 14 ++ .../Sybil/Sources/Sybil/SybilAgentRoom.swift | 6 + .../Sybil/Sources/Sybil/SybilAgentView.swift | 99 ++++++++---- .../Sources/Sybil/SybilMessageText.swift | 3 + ios/Tests/AgentPerformance.md | 68 +++++++++ ios/Tests/Fixtures/agent_server.py | 12 ++ .../AgentPerformanceTests.swift | 142 ++++++++++++++++++ ios/Tests/SybilUITests/AgentUITests.swift | 18 +++ 8 files changed, 332 insertions(+), 30 deletions(-) create mode 100644 ios/Packages/Sybil/Sources/Sybil/AgentRenderDiagnostics.swift create mode 100644 ios/Tests/AgentPerformance.md create mode 100644 ios/Tests/SybilPresentationTests/AgentPerformanceTests.swift diff --git a/ios/Packages/Sybil/Sources/Sybil/AgentRenderDiagnostics.swift b/ios/Packages/Sybil/Sources/Sybil/AgentRenderDiagnostics.swift new file mode 100644 index 0000000..b1dff4e --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/AgentRenderDiagnostics.swift @@ -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 diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentRoom.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentRoom.swift index 5ccaa5c..24d6e13 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentRoom.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentRoom.swift @@ -74,6 +74,9 @@ final class SybilAgentRoom { var isBusy: Bool { snapshot.room.activity.isBusy } var unreadCount: Int { snapshot.room.unreadCount } var messages: [Message] { + #if DEBUG + AgentRenderDiagnostics.record("messageProjection") + #endif let committed = Set(snapshot.room.messages.map(\.id)) let queued = snapshot.room.activity.queuedMessages.filter { !committed.contains($0.id) } var messages = AgentRoomState.merge(snapshot.room.messages, queued) @@ -337,6 +340,9 @@ final class SybilAgentRoom { } func flush() { + #if DEBUG + AgentRenderDiagnostics.record("snapshotPublish") + #endif batchTask?.cancel(); batchTask = nil typingTask?.cancel(); typingTask = nil snapshot = Snapshot(room: pending, isTyping: presenter.isTyping(now: Date()), entranceIDs: pendingEntrances, revision: snapshot.revision + 1) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift index 8f249c0..4dca7aa 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift @@ -11,22 +11,25 @@ struct SybilAgentView: View { @State private var historyLimit = 100 @State private var historyStartID: String? @State private var scrollPosition = ScrollPosition() - @State private var scrollMetrics = AgentScrollMetrics() - @State private var rowFrames: [String: CGRect] = [:] + @State private var scrollTracking = AgentScrollTracking() + @State private var atBottom = false @State private var restoreAnchor: AgentReadingAnchor? @State private var appeared = false private let bottomID = "agent-bottom" 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? { - 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 } .map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) } } var body: some View { + #if DEBUG + let _ = AgentRenderDiagnostics.record("agentBody") + #endif let rendered = room.snapshot let transcript = AgentTranscript(messages: room.messages, startID: historyStartID, limit: historyLimit) let allMessages = transcript.messages @@ -51,21 +54,11 @@ struct SybilAgentView: View { } if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) } ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in - VStack(spacing: 14) { - if index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt) { - 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, 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) + AgentTranscriptRow(message: message, room: room, + startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt)) + .equatable() + .modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion)) + .id(message.id) } if rendered.isTyping { HStack(spacing: 9) { @@ -103,7 +96,11 @@ struct SybilAgentView: View { bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top, visibleBottom: geometry.visibleRect.maxY) } 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 atLatest, !isScrolling, restoreAnchor == nil, previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset { @@ -111,23 +108,26 @@ struct SybilAgentView: View { } } .onPreferenceChange(AgentRowFrames.self) { frames in + #if DEBUG + AgentRenderDiagnostics.record("rowPreferences") + #endif let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil) if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 { // 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), scrollMetrics.maximumOffset) - let appliedShift = target - scrollMetrics.offset - rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) } + let requested = scrollTracking.metrics.offset + frame.minY - anchor.offset + let target = min(max(requested, -scrollTracking.metrics.topInset), scrollTracking.metrics.maximumOffset) + let appliedShift = target - scrollTracking.metrics.offset + scrollTracking.rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) } if abs(appliedShift) > 1 { // Geometry reports the raw offset; ScrollPosition uses // zero at the top inset, including the safe area. - scrollPosition.scrollTo(y: target + scrollMetrics.topInset) + scrollPosition.scrollTo(y: target + scrollTracking.metrics.topInset) } restoreAnchor = nil } else { - rowFrames = frames + scrollTracking.rowFrames = frames 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 // the task, and cannot be acknowledged while still entering the layout. 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)) - 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) } } @@ -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 { var message: Message var room: SybilAgentRoom - var retry: () -> Void private var own: Bool { message.role == .user } private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" } var body: some View { @@ -257,7 +288,7 @@ private struct AgentMessageRow: View { if own && status == "queued" { Label("Queued", systemImage: "clock") } if own && status == "failed" { 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) @@ -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 { var offset: CGFloat = 0 var height: CGFloat = 0 diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilMessageText.swift b/ios/Packages/Sybil/Sources/Sybil/SybilMessageText.swift index a1e771f..84a45a9 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilMessageText.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilMessageText.swift @@ -28,6 +28,9 @@ struct SybilMessageText: View { } var body: some View { + #if DEBUG + let _ = AgentRenderDiagnostics.record("messageTextBuild") + #endif Markdown(format == .literal ? Self.literalContent(content) : MarkdownContent(content)) .tint(SybilTheme.primary) .foregroundStyle(format == .literal ? SybilTheme.text : SybilTheme.text.opacity(0.95)) diff --git a/ios/Tests/AgentPerformance.md b/ios/Tests/AgentPerformance.md new file mode 100644 index 0000000..ba45668 --- /dev/null +++ b/ios/Tests/AgentPerformance.md @@ -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. diff --git a/ios/Tests/Fixtures/agent_server.py b/ios/Tests/Fixtures/agent_server.py index 179052c..2e4dcad 100644 --- a/ios/Tests/Fixtures/agent_server.py +++ b/ios/Tests/Fixtures/agent_server.py @@ -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())) diff --git a/ios/Tests/SybilPresentationTests/AgentPerformanceTests.swift b/ios/Tests/SybilPresentationTests/AgentPerformanceTests.swift new file mode 100644 index 0000000..ce67e2e --- /dev/null +++ b/ios/Tests/SybilPresentationTests/AgentPerformanceTests.swift @@ -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] + } +} diff --git a/ios/Tests/SybilUITests/AgentUITests.swift b/ios/Tests/SybilUITests/AgentUITests.swift index bc7b033..a7d7dd9 100644 --- a/ios/Tests/SybilUITests/AgentUITests.swift +++ b/ios/Tests/SybilUITests/AgentUITests.swift @@ -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")