diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 60e5281260df6..6436fa9e8ff01 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -273,8 +273,10 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, cacheStartupRefreshTask = Task { [weak self] in guard let self else { return } self.routeSnapshotLoads += 1 - defer { self.routeSnapshotLoads -= 1 } - let loads = await self.loadEnvironmentShells(environments.filter(\.isEnabled), loadGenerations: loadGenerations) + let loads = await self.loadEnvironmentShells( + environments.filter { $0.isEnabled && $0.id == environment.id }, loadGenerations: loadGenerations + ) + self.routeSnapshotLoads -= 1 guard !Task.isCancelled, self.isCurrentSession(client: activeClient, generation: generation) else { return } self.reconcileEnvironmentLoads(loads, savedEnvironments: environments) self.latestShell = self.shellsByEnvironmentID[environment.id] @@ -283,20 +285,26 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, connectionState: self.environmentConnectionStates[environment.id] ?? .disconnected, connectionDetail: self.environmentConnectionDetails[environment.id] )) - if loads.first(where: { $0.environment.id == environment.id })?.shell != nil { + if loads.first?.shell != nil { self.scheduleArchivedRefresh(client: activeClient, environment: environment) } + await self.loadPassiveStartupShells( + environments, activeEnvironment: environment, client: activeClient, + generation: generation, loadGenerations: loadGenerations + ) } return snapshot } - let loads = await loadEnvironmentShells(environments.filter(\.isEnabled), loadGenerations: loadGenerations) + let loads = await loadEnvironmentShells( + environments.filter { $0.isEnabled && $0.id == environment.id }, loadGenerations: loadGenerations + ) guard isCurrentSession(client: activeClient, generation: generation) else { throw CancellationError() } reconcileEnvironmentLoads(loads, savedEnvironments: environments) latestShell = shellsByEnvironmentID[environment.id] startPolling(activeClient) - let activeLoad = loads.first { $0.environment.id == environment.id } + let activeLoad = loads.first let activeIsReachable = activeLoad?.shell != nil if activeIsReachable { scheduleArchivedRefresh(client: activeClient, environment: environment) @@ -311,9 +319,37 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, connectionDetail: environmentConnectionDetails[environment.id] ) latestSnapshot = snapshot + cacheStartupRefreshTask?.cancel() + cacheStartupRefreshTask = Task { [weak self] in + await self?.loadPassiveStartupShells( + environments, activeEnvironment: environment, client: activeClient, + generation: generation, loadGenerations: loadGenerations + ) + } return snapshot } + /// Passive computers never gate the first home snapshot: one hung host + /// would otherwise hold the whole home for its shell timeout. Like the + /// aggregate refresh, this does not count as a route snapshot load, so it + /// cannot defer active route recovery either. + private func loadPassiveStartupShells( + _ environments: [Environment], activeEnvironment: Environment, client: T3Client, + generation: Int, loadGenerations: [String: Int] + ) async { + let passiveEnvironments = environments.filter { $0.isEnabled && $0.id != activeEnvironment.id } + guard !passiveEnvironments.isEmpty, !Task.isCancelled else { return } + let loads = await loadEnvironmentShells(passiveEnvironments, loadGenerations: loadGenerations) + guard !Task.isCancelled, isCurrentSession(client: client, generation: generation) else { return } + reconcileEnvironmentLoads(loads, savedEnvironments: environments) + let connection = latestSnapshot?.connection + publish(makeSnapshot( + environments: environments, activeEnvironment: activeEnvironment, + connectionState: connection?.state ?? environmentConnectionStates[activeEnvironment.id] ?? .disconnected, + connectionDetail: connection.map(\.detail) ?? environmentConnectionDetails[activeEnvironment.id] + )) + } + func resumeAfterBackground(reconnect: Bool) async { let routeEnvironmentIDs = Set([activeEnvironment?.id, activeThreadEnvironmentID].compactMap { $0 }) for id in routeEnvironmentIDs { @@ -9280,6 +9316,13 @@ extension NativeFeatureClient { } } + /// Waits for the passive computers `initialSnapshot` left loading, then + /// returns what Home now shows. Tests use this boundary instead of events. + func settledStartupSnapshot() async -> FeatureSnapshot? { + await cacheStartupRefreshTask?.value + return latestSnapshot + } + /// Waits for this client's queued mutations, then flushes its coalesced writes. /// Tests use this boundary instead of timers. func flushClientReadCache() async throws { diff --git a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift index 6016eb151b9f3..79635ad292365 100644 --- a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift @@ -28,7 +28,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { fallbackPollingInitialDelay: .seconds(60), aggregateRefreshInterval: .seconds(60) ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - let initial = try await fixture.client.initialSnapshot() + let initial = try await fixture.client.hydratedInitialSnapshot() let project = try XCTUnwrap(initial.projects.first { $0.environmentID == "two" }) XCTAssertEqual(initial.preferencesByEnvironment?["one"]?.defaultRuntimeMode, .approvalRequired) XCTAssertEqual(initial.preferencesByEnvironment?["two"]?.defaultRuntimeMode, .autoAcceptEdits) @@ -64,7 +64,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { fallbackPollingInitialDelay: .seconds(60), aggregateRefreshInterval: .seconds(60) ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() try await fixture.client.updateServerPreferences(environmentID: "one", change: .autoResumeLimitedThreads(true)) try await fixture.client.updateServerPreferences(environmentID: "one", change: .snoozeLimitedThreads(true)) let hosts = await server.updatedHosts() @@ -94,7 +94,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { snapshotSequence: source.snapshotSequence, projects: source.projects, threads: [child], updatedAt: source.updatedAt ), host: "two.example") - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let mapped = try XCTUnwrap(snapshot.threads.first { $0.wireID == "child" }) XCTAssertTrue(mapped.isSubagent) let restored = try JSONDecoder.t3.decode(FeatureSnapshot.self, from: JSONEncoder.t3.encode(snapshot)) @@ -116,7 +116,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { rpcConnectionWaitTimeout: .seconds(2) ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() let id = try await fixture.client.ensureScratchProject(environmentID: "two") XCTAssertEqual(id, FeatureScopedID.project(environmentID: "two", wireID: "scratch")) let hosts = await server.scratchHosts() @@ -134,7 +134,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { rpcConnectionWaitTimeout: .seconds(2) ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() try await fixture.client.updateServerPreferences(environmentID: "two", change: .storageCleanup(["logsAfterDays": .number(14)])) try await fixture.client.updateServerPreferences(environmentID: "two", change: .worktreeSubmodules(WorktreeSubmodules.none)) let hosts = await server.updatedHosts() @@ -161,7 +161,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { projectID: "project-shared", threadID: "thread-shared", title: "Remote files", workspaceRoot: #"C:\work\project"#, worktreePath: #"D:\worktrees\feature"# ), host: "two.example") - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try XCTUnwrap(snapshot.threads.first { $0.environmentID == "two" }) let root = try await fixture.client.listFiles(threadID: thread.id, path: nil) @@ -193,7 +193,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { rpcConnectionWaitTimeout: .seconds(2) ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try XCTUnwrap(snapshot.threads.first { $0.environmentID == "one" }) let root = try await fixture.client.listFiles(threadID: thread.id, path: nil) @@ -238,7 +238,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { fallbackPollingInitialDelay: .seconds(60), aggregateRefreshInterval: .seconds(60) ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let project = try XCTUnwrap(snapshot.projects.first { $0.environmentID == "two" }) XCTAssertEqual(project.defaultSelection?.modelID, environmentDefault.model) _ = try await fixture.client.createThread(projectID: project.id, title: "Task", selection: nil) @@ -268,7 +268,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { fallbackPollingInitialDelay: .seconds(60), aggregateRefreshInterval: .seconds(60) ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let project = try XCTUnwrap(snapshot.projects.first { $0.environmentID == "two" }) XCTAssertEqual(project.defaultSelection?.modelID, environmentDefault.model) XCTAssertEqual(project.supportsProjectSettingsOverrides, true) @@ -340,7 +340,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { snapshotSequence: source.snapshotSequence, projects: [wireProject], threads: source.threads, updatedAt: source.updatedAt ), host: "two.example") - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let project = try XCTUnwrap(snapshot.projects.first { $0.environmentID == "two" }) let preferences = try await fixture.client.projectPreferences(projectID: project.id) XCTAssertEqual(preferences.environment.defaultModelSelection?.model, "environment-model") @@ -358,7 +358,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { fallbackPollingInitialDelay: .seconds(60), aggregateRefreshInterval: .seconds(60) ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let project = try XCTUnwrap(snapshot.projects.first { $0.environmentID == "two" }) do { try await fixture.client.updateProjectPreferences(projectID: project.id, change: .init( @@ -386,7 +386,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { fallbackPollingInitialDelay: .seconds(60), aggregateRefreshInterval: .seconds(60) ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() try await fixture.client.updateServerPreferences(environmentID: "two", change: .responseStreamingMode(.token)) let saved = try await fixture.client.serverPreferences(environmentID: "two") XCTAssertEqual(saved.responseStreamingMode, .token) @@ -414,7 +414,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { ), host: "two.example" ) - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try XCTUnwrap(snapshot.threads.first { $0.environmentID == "two" }) let monitor = Task { for await _ in client.sourceControlStatusEvents(threadID: thread.id) {} @@ -440,7 +440,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { await client.disconnect() try? FileManager.default.removeItem(at: directory) } - let snapshot = try await client.initialSnapshot() + let snapshot = try await client.hydratedInitialSnapshot() let thread = try XCTUnwrap(snapshot.threads.first { $0.environmentID == "two" }) let other = try XCTUnwrap(snapshot.threads.first { $0.environmentID == "one" }) @@ -566,7 +566,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { let fixture = try await Self.makeFixture() defer { try? FileManager.default.removeItem(at: fixture.directory) } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() XCTAssertEqual(Set(snapshot.projects.map(\.environmentID)), ["one", "two"]) XCTAssertEqual(Set(snapshot.threads.compactMap(\.wireID)), ["thread-one", "thread-two"]) @@ -632,7 +632,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { aggregateRefreshInterval: .seconds(60) ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() let providers = try await fixture.client.refreshProviders(environmentID: "two") XCTAssertEqual(providers.map(\.id), ["codex-two.example"]) @@ -684,7 +684,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { aggregateRefreshInterval: .seconds(60) ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() try await fixture.client.updateServerPreferences( environmentID: "two", change: .environmentIcon("mac-mini") @@ -721,7 +721,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { snapshotSequence: source.snapshotSequence, projects: [project], threads: source.threads, updatedAt: source.updatedAt ), host: "two.example") - let initial = try await fixture.client.initialSnapshot() + let initial = try await fixture.client.hydratedInitialSnapshot() XCTAssertNil(initial.projects.first { $0.environmentID == "two" }?.defaultSelection) let localDefault = initial.projects.first { $0.environmentID == "one" }?.defaultSelection @@ -736,7 +736,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { } await fixture.transport.setShell(source, host: "two.example") - let overridden = try await fixture.client.initialSnapshot() + let overridden = try await fixture.client.hydratedInitialSnapshot() XCTAssertEqual(overridden.projects.first { $0.environmentID == "two" }?.defaultSelection?.modelID, "gpt-5.6-sol") await fixture.client.disconnect() } @@ -751,7 +751,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { aggregateRefreshInterval: .seconds(60) ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() try await fixture.client.updateServerPreferences( environmentID: "one", change: .defaultThreadEnvMode(.worktree) @@ -780,7 +780,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { aggregateRefreshInterval: .seconds(60) ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() XCTAssertEqual(snapshot.preferencesByEnvironment?["one"]?.continueThreadsAfterServerUpdate, false) XCTAssertNil(snapshot.preferencesByEnvironment?["two"]?.continueThreadsAfterServerUpdate) @@ -826,7 +826,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { host: "one.example" ) - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try XCTUnwrap( snapshot.threads.first(where: { $0.wireID == "thread-one" }) ) @@ -869,7 +869,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { host: "two.example" ) - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try XCTUnwrap(snapshot.threads.first { $0.environmentID == "two" }) let detail = try await fixture.client.loadThread(id: thread.id) @@ -901,7 +901,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { host: "two.example" ) - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try XCTUnwrap(snapshot.threads.first { $0.environmentID == "two" }) let detail = try await fixture.client.loadThread(id: thread.id) @@ -947,7 +947,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { host: "two.example" ) - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try XCTUnwrap(snapshot.threads.first { $0.environmentID == "two" }) XCTAssertTrue(thread.isRegeneratingTitle) @@ -975,7 +975,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { let fixture = try await Self.makeFixture(repositoryIdentity: identity) defer { try? FileManager.default.removeItem(at: fixture.directory) } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let groups = DailyUXCreationContext.projectGroups(in: snapshot) XCTAssertEqual(Set(snapshot.projects.compactMap(\.repositoryIdentity?.canonicalKey)), [ @@ -990,10 +990,10 @@ final class NativeMultiEnvironmentTests: XCTestCase { let fixture = try await Self.makeFixture() defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() await fixture.transport.setReachable(false, host: "two.example") - let passiveFailure = try await fixture.client.initialSnapshot() + let passiveFailure = try await fixture.client.hydratedInitialSnapshot() XCTAssertEqual( Set(passiveFailure.threads.compactMap(\.wireID)), ["thread-one", "thread-two"] @@ -1007,7 +1007,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { await fixture.transport.setReachable(false, host: "one.example") await fixture.transport.setReachable(true, host: "two.example") - let activeFailure = try await fixture.client.initialSnapshot() + let activeFailure = try await fixture.client.hydratedInitialSnapshot() XCTAssertEqual( Set(activeFailure.threads.compactMap(\.wireID)), ["thread-one", "thread-two"] @@ -1021,10 +1021,49 @@ final class NativeMultiEnvironmentTests: XCTestCase { await fixture.client.disconnect() } + func testColdStartDoesNotWaitForAPassiveComputerAndPublishesItAfterwards() async throws { + let fixture = try await Self.makeFixture( + fallbackPollingInitialDelay: .seconds(60), aggregateRefreshInterval: .seconds(60) + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await fixture.transport.holdNextShellRead(host: "two.example") + + let activeStartup = expectation(description: "Active startup must return before a passive computer answers") + var startupReturned = false + let startup = Task { + let snapshot = try await fixture.client.initialSnapshot() + startupReturned = true + activeStartup.fulfill() + return snapshot + } + await fixture.transport.waitForHeldShellRead() + await fulfillment(of: [activeStartup], timeout: 2) + let returnedBeforeRelease = startupReturned + // Release even when the expectation fails so the old startup path can + // finish and does not leave the transport continuation suspended. + await fixture.transport.releaseShellRead() + let initial = try await startup.value + guard returnedBeforeRelease else { + await fixture.client.disconnect() + return + } + XCTAssertEqual(Set(initial.threads.compactMap(\.wireID)), ["thread-one"]) + XCTAssertEqual(initial.connection.state, .connected) + XCTAssertNil(initial.environments.first(where: { $0.id == "two" })?.connectionState) + + let settled = await fixture.client.settledStartupSnapshot() + let passive = try XCTUnwrap(settled) + XCTAssertEqual(Set(passive.threads.compactMap(\.wireID)), ["thread-one", "thread-two"]) + XCTAssertEqual(passive.environments.first(where: { $0.id == "two" })?.connectionState, .connected) + XCTAssertEqual(passive.connection.state, .connected) + XCTAssertEqual(passive.connection.environmentName, "Left Book") + await fixture.client.disconnect() + } + func testCachedShellRowsApplySettlementAndRemoveDeletedRoutes() async throws { let fixture = try await Self.makeFixture(threadSettlementAvailable: true) defer { try? FileManager.default.removeItem(at: fixture.directory) } - let initial = try await fixture.client.initialSnapshot() + let initial = try await fixture.client.hydratedInitialSnapshot() let original = try XCTUnwrap(initial.threads.first { $0.environmentID == "two" }) let updated = multiEnvironmentShell( projectID: "project-two", threadID: "thread-two", title: "Remote work", @@ -1033,7 +1072,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { settledOverride: "settled", settledAt: "2026-07-31T12:01:00.000Z" ) await fixture.transport.setShell(updated, host: "two.example") - let refreshed = try await fixture.client.initialSnapshot() + let refreshed = try await fixture.client.hydratedInitialSnapshot() let settled = try XCTUnwrap(refreshed.threads.first { $0.id == original.id }) XCTAssertEqual(settled.updatedAt, original.updatedAt) XCTAssertEqual(settled.supportsSettlement, true) @@ -1056,7 +1095,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { ), host: "two.example" ) - let removed = try await fixture.client.initialSnapshot() + let removed = try await fixture.client.hydratedInitialSnapshot() XCTAssertFalse(removed.threads.contains { $0.id == original.id }) XCTAssertEqual(removed.projects.first { $0.environmentID == "two" }?.threadCount, 0) do { @@ -1071,7 +1110,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { func testOlderHTTPSnapshotCannotReplaceNewerEnvironmentState() async throws { let fixture = try await Self.makeFixture() defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() let newer = multiEnvironmentShell( projectID: "project-one", @@ -1116,7 +1155,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { func testThreadCreationCannotReplaceNewerEnvironmentStateWithAnOlderShell() async throws { let fixture = try await Self.makeFixture() defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() let newer = multiEnvironmentShell( projectID: "project-one", @@ -1229,7 +1268,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() await loader.waitForCallCount(2) let retryCallCount = await loader.callCount @@ -1247,7 +1286,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() await loader.waitForCallCount(1) _ = try await fixture.client.initialSnapshot() @@ -1263,7 +1302,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { let fixture = try await Self.makeFixture(duplicateIDs: true) defer { try? FileManager.default.removeItem(at: fixture.directory) } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() XCTAssertEqual(snapshot.projects.count, 2) XCTAssertEqual(snapshot.threads.count, 2) XCTAssertEqual(Set(snapshot.projects.map(\.id)).count, 2) @@ -1286,7 +1325,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { let fixture = try await Self.makeFixture() defer { try? FileManager.default.removeItem(at: fixture.directory) } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let remoteProject = try XCTUnwrap( snapshot.projects.first(where: { $0.environmentID == "two" }) ) @@ -1320,7 +1359,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { func testPassiveCreateRecoversACommittedThreadAfterItsReplyIsLost() async throws { let fixture = try await Self.makeFixture() defer { try? FileManager.default.removeItem(at: fixture.directory) } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let project = try XCTUnwrap( snapshot.projects.first(where: { $0.environmentID == "two" }) ) @@ -1345,7 +1384,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { func testUnarchiveImmediatelyRestoresLiveThreadWhenRefreshIsUnavailable() async throws { let fixture = try await Self.makeFixture() defer { try? FileManager.default.removeItem(at: fixture.directory) } - let initial = try await fixture.client.initialSnapshot() + let initial = try await fixture.client.hydratedInitialSnapshot() let thread = try XCTUnwrap( initial.threads.first(where: { $0.environmentID == "one" }) ) @@ -1384,7 +1423,7 @@ final class NativeMultiEnvironmentTests: XCTestCase { fallbackPollingInterval: .seconds(2) ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() let current = multiEnvironmentShell( projectID: "project-one", threadID: "thread-one", @@ -1653,7 +1692,7 @@ struct NativeGitHubRoutingTests { webSocketConnector: MultiEnvironmentConfigurationConnector(server: server), rpcConnectionWaitTimeout: .seconds(2) ) - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() for id in includeThird ? ["one", "two", "three"] : ["one", "two"] { try await fixture.client.setGitHubRoutingPermission(environmentID: id, permission: .readWrite) } @@ -1676,7 +1715,7 @@ struct NativePassiveThreadRefreshTests { } ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - let initial = try await fixture.client.initialSnapshot() + let initial = try await fixture.client.hydratedInitialSnapshot() let thread = try #require( initial.threads.first(where: { $0.environmentID == "two" }) ) @@ -1718,7 +1757,7 @@ struct NativePassiveThreadRefreshTests { } ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() let firstCadence = await refreshSleep.waitUntilRequested(count: 1) #expect(firstCadence == .seconds(5)) @@ -1738,7 +1777,7 @@ struct NativePassiveThreadRefreshTests { } ) defer { try? FileManager.default.removeItem(at: fixture.directory) } - _ = try await fixture.client.initialSnapshot() + _ = try await fixture.client.hydratedInitialSnapshot() let initialShellReads = await fixture.transport.shellReadCount(host: "three.example") let initialPeerReads = await fixture.transport.shellReadCount(host: "two.example") #expect(initialShellReads == 1) @@ -2065,6 +2104,15 @@ private actor ControllableAggregateRefreshSleep { } } +extension NativeFeatureClient { + /// Cold start returns once the active computer loads; passive computers + /// publish afterwards. Returns the snapshot once they have. + func hydratedInitialSnapshot() async throws -> FeatureSnapshot { + let initial = try await initialSnapshot() + return await settledStartupSnapshot() ?? initial + } +} + private struct MultiEnvironmentFixture { let directory: URL let transport: MultiEnvironmentHTTPTransport diff --git a/apps/swift-ios/Tests/FeatureTests/NativeSubagentRequestTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeSubagentRequestTests.swift index f7c9e3d49852f..c7d4331e582a4 100644 --- a/apps/swift-ios/Tests/FeatureTests/NativeSubagentRequestTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/NativeSubagentRequestTests.swift @@ -10,7 +10,7 @@ struct NativeSubagentRequestTests { defer { fixture.cleanUp() } let client = fixture.client() do { - let snapshot = try await client.initialSnapshot() + let snapshot = try await client.hydratedInitialSnapshot() let cases: [(String, OrchestrationV2PendingSubagentRequest)] = [("one", .userInput), ("two", .approval)] for (environmentID, request) in cases { let id = FeatureScopedID.thread(environmentID: environmentID, wireID: "parent") diff --git a/apps/swift-ios/Tests/FeatureTests/NativeWorkspacePermissionTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeWorkspacePermissionTests.swift index 45f7af9c97d3a..1b460f2867ca3 100644 --- a/apps/swift-ios/Tests/FeatureTests/NativeWorkspacePermissionTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/NativeWorkspacePermissionTests.swift @@ -8,7 +8,7 @@ struct NativeWorkspacePermissionTests { @Test func revokedFileAccessDoesNotBorrowAnotherEnvironmentGrant() async throws { let fixture = try await WorkspacePermissionFixture.make() defer { fixture.cleanUp() } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try #require(snapshot.threads.first { $0.environmentID == "two" }) let project = try #require(snapshot.projects.first { $0.environmentID == "two" }) _ = try await fixture.client.listFiles(threadID: thread.id, path: nil) @@ -36,7 +36,7 @@ struct NativeWorkspacePermissionTests { @Test func attachmentReadUsesOrchestrationPermissionEvenWithCachedURL() async throws { let fixture = try await WorkspacePermissionFixture.make() defer { fixture.cleanUp() } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try #require(snapshot.threads.first { $0.environmentID == "two" }) await fixture.server.setPermissions(["orchestration:read"], host: "two.example") let attachment = FeatureMessageAttachment(id: "attachment", name: "image.png", mimeType: "image/png", sizeBytes: 1) @@ -56,7 +56,7 @@ struct NativeWorkspacePermissionTests { func branchSwitchRechecksBothGrantsAfterListingRefs(revokedScope: String) async throws { let fixture = try await WorkspacePermissionFixture.make() defer { fixture.cleanUp() } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try #require(snapshot.threads.first { $0.environmentID == "two" }) await fixture.server.revoke(revokedScope, after: "vcs.listRefs") do { @@ -74,7 +74,7 @@ struct NativeWorkspacePermissionTests { @Test func scriptRechecksOperateAfterOpeningBeforeSendingInput() async throws { let fixture = try await WorkspacePermissionFixture.make() defer { fixture.cleanUp() } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try #require(snapshot.threads.first { $0.environmentID == "two" }) await fixture.server.revoke("terminal:operate", after: "terminal.open") await #expect(throws: EnvironmentPermissionDeniedError.self) { @@ -94,7 +94,7 @@ struct NativeWorkspacePermissionTests { func pullRequestPreparationRequiresBothGrants(revokedScope: String) async throws { let fixture = try await WorkspacePermissionFixture.make() defer { fixture.cleanUp() } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let project = try #require(snapshot.projects.first { $0.environmentID == "two" }) let permissions = ["orchestration:read", "source-control:write", "orchestration:operate"] await fixture.server.setPermissions(permissions.filter { $0 != revokedScope }, host: "two.example") @@ -115,7 +115,7 @@ struct NativeWorkspacePermissionTests { @Test func pullRequestPreparationRechecksOperateBeforeLaunch() async throws { let fixture = try await WorkspacePermissionFixture.make() defer { fixture.cleanUp() } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let project = try #require(snapshot.projects.first { $0.environmentID == "two" }) await fixture.server.revoke("orchestration:operate", after: "git.preparePullRequestThread") await #expect(throws: EnvironmentPermissionDeniedError.self) { diff --git a/apps/swift-ios/Tests/FeatureTests/TerminalPermissionTests.swift b/apps/swift-ios/Tests/FeatureTests/TerminalPermissionTests.swift index 5ffad649b76a9..7329af90ecd1f 100644 --- a/apps/swift-ios/Tests/FeatureTests/TerminalPermissionTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/TerminalPermissionTests.swift @@ -8,7 +8,7 @@ struct TerminalPermissionTests { @Test func readOnlySessionObservesWithoutAnyPTYMutation() async throws { let fixture = try await TerminalPermissionFixture.make() defer { fixture.cleanUp() } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try #require(snapshot.threads.first { $0.environmentID == "two" }) let initial = try await fixture.client.terminalSnapshot(threadID: thread.id, terminalID: "default") #expect(initial.state == .stopped) @@ -32,7 +32,7 @@ struct TerminalPermissionTests { let fixture = try await TerminalPermissionFixture.make() defer { fixture.cleanUp() } await fixture.server.setPermissions(["terminal:read", "terminal:operate"], host: "two.example") - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try #require(snapshot.threads.first { $0.environmentID == "two" }) _ = try await fixture.client.terminalSnapshot(threadID: thread.id, terminalID: "default") #expect(fixture.client.permissions(forThreadID: thread.id)?.grants("terminal:operate") == true) @@ -60,7 +60,7 @@ struct TerminalPermissionTests { @Test func currentOperateGrantDoesNotGrantTerminalReadsOrCachedOutput() async throws { let fixture = try await TerminalPermissionFixture.make() defer { fixture.cleanUp() } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try #require(snapshot.threads.first { $0.environmentID == "two" }) var readable = fixture.client.terminalEvents(threadID: thread.id, terminalID: "default").makeAsyncIterator() #expect(await readable.next()?.buffer == "$ existing output\r\n") @@ -82,7 +82,7 @@ struct TerminalPermissionTests { @Test func unsupportedObservationNeverFallsBackToAttach() async throws { let fixture = try await TerminalPermissionFixture.make() defer { fixture.cleanUp() } - let snapshot = try await fixture.client.initialSnapshot() + let snapshot = try await fixture.client.hydratedInitialSnapshot() let thread = try #require(snapshot.threads.first { $0.environmentID == "two" }) await fixture.server.rejectObservation() var events = fixture.client.terminalEvents(threadID: thread.id, terminalID: "default").makeAsyncIterator()