From a078f84686dd4fd9e77277ed689a2bb97cd0f82a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:55:14 +0800 Subject: [PATCH 1/2] fix(context-builder): hydrate persisted model authority Fixes #561 --- .../Fixtures/test-suite-contract-ledger.tsv | 1 + .../ContextBuilderAgentViewModel.swift | 15 +++++-- .../Prompt/ViewModels/PromptViewModel.swift | 14 +++++-- ...lSettingsCrossWindowPropagationTests.swift | 41 +++++++++++++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index 678840a9e..2029e1922 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -2325,6 +2325,7 @@ root/RepoPromptTests.SettingsJSONOnlyPersistenceTests/testUserInitiatedRecoveryB root/RepoPromptTests.GlobalSettingsCrossWindowPropagationTests/testOracleModelChangePropagatesAcrossWindows root Tests/RepoPromptTests/Prompt/GlobalSettingsCrossWindowPropagationTests.swift RepoPromptTests.GlobalSettingsCrossWindowPropagationTests testOracleModelChangePropagatesAcrossWindows Root unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.001000 unreviewed retain_pending_review 0 PR #418 oracle model cross-window propagation root/RepoPromptTests.GlobalSettingsCrossWindowPropagationTests/testOraclePropagationDoesNotFeedbackLoop root Tests/RepoPromptTests/Prompt/GlobalSettingsCrossWindowPropagationTests.swift RepoPromptTests.GlobalSettingsCrossWindowPropagationTests testOraclePropagationDoesNotFeedbackLoop Root unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.001000 unreviewed retain_pending_review 0 PR #418 cross-window re-sync no feedback loop root/RepoPromptTests.GlobalSettingsCrossWindowPropagationTests/testContextBuilderPickerExplicitCommitPersistsDisplayedRuntimeFallback root Tests/RepoPromptTests/Prompt/GlobalSettingsCrossWindowPropagationTests.swift RepoPromptTests.GlobalSettingsCrossWindowPropagationTests testContextBuilderPickerExplicitCommitPersistsDisplayedRuntimeFallback Settings settings.context_builder.explicit_fallback_commit runtime_fallback,explicit_commit,global_selection deterministic_persistence root_swiftpm routine 1 isolated_settings_store,isolated_user_defaults Explicitly committing the displayed Context Builder fallback persists the selected provider and model even when property observers do not fire. Re-selecting the displayed fallback could leave MCP and UI selections out of sync. 0.001000 user_defaults method_local_values+defer retain 0 PR #463 explicit Context Builder fallback commit +root/RepoPromptTests.GlobalSettingsCrossWindowPropagationTests/testGlobalContextBuilderSelectionUpdatesPopoverProjectionAndLaunchAuthority root Tests/RepoPromptTests/Prompt/GlobalSettingsCrossWindowPropagationTests.swift RepoPromptTests.GlobalSettingsCrossWindowPropagationTests testGlobalContextBuilderSelectionUpdatesPopoverProjectionAndLaunchAuthority Settings settings.context_builder.global_selection_authority global_selection,popover_projection,launch_authority,persistence deterministic_persistence root_swiftpm routine 1 isolated_settings_store,isolated_user_defaults Global Agent Models selection is projected identically to the Context Builder popover and launch resolver. Global persisted Codex selection could be displayed as Claude and route launches through the wrong provider. 0.131000 user_defaults method_local_values+defer retain 0 Issue #561 global Context Builder model authority regression root/RepoPromptTests.SourceTargetTestConstructorGuardTests/testSourceTargetContainsNoProvenanceOrBindingTestConstructors root Tests/RepoPromptTests/CodeMap/SourceTargetTestConstructorGuardTests.swift RepoPromptTests.SourceTargetTestConstructorGuardTests testSourceTargetContainsNoProvenanceOrBindingTestConstructors CodeMap codemap.source_target.no_test_constructors source_scan,test_fixture_symbol,test_only_constructor,authority,completion,validated_snapshot repository_guard root_swiftpm routine 3 SourceTreeScanFixture Every Swift file under Sources/RepoPrompt is scanned and fails if provenance, authority, or completion test-constructor symbols are compiled into the app target. A DEBUG-only backdoor could re-enter production Sources and bypass service-issued provenance or binding factories. 1.267000 filesystem,security repository_checkout test_case retain 0 Final Slice 1A P2 source-target constructor guard root/RepoPromptTests.StoreBackedWorkspaceSearchConcurrencyMatrixTests/testConcurrentSameStoreAndSeparateStoreMatrixPreservesOrderingIsolationAndCleanup root Tests/RepoPromptTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchConcurrencyMatrixTests.swift RepoPromptTests.StoreBackedWorkspaceSearchConcurrencyMatrixTests testConcurrentSameStoreAndSeparateStoreMatrixPreservesOrderingIsolationAndCleanup WorkspaceContext unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.626000 unreviewed retain_pending_review 0 initial census source line 22 root/RepoPromptTests.StoreBackedWorkspaceSearchLaneTests/testBypassAccessDoesNotWaitForHeldBroadPermit root Tests/RepoPromptTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchLaneTests.swift RepoPromptTests.StoreBackedWorkspaceSearchLaneTests testBypassAccessDoesNotWaitForHeldBroadPermit WorkspaceContext unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.007500 unreviewed retain_pending_review 0 initial census source line 169 diff --git a/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift b/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift index 2006686f5..41a6964c3 100644 --- a/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift +++ b/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift @@ -1040,18 +1040,25 @@ final class ContextBuilderAgentViewModel: ObservableObject { } private func resolvedPersistedContextBuilderSelection(workspaceID: UUID? = nil) -> AgentModelCatalog.NormalizedAgentSelection? { - guard let apiSettingsViewModel = promptManager.apiSettingsViewModel, - apiSettingsViewModel.isContextBuilderProviderValidationComplete - else { + guard let apiSettingsViewModel = promptManager.apiSettingsViewModel else { return nil } let profile = settingsManager.effectiveAgentModelsProfile(workspaceID: workspaceID ?? currentWorkspaceID) let agentRaw = profile.contextBuilderAgentRaw let modelRaw = agentRaw.flatMap { profile.contextBuilderModelsByAgent?[$0] } + let availability = apiSettingsViewModel.contextBuilderRestorationAvailabilityContext + guard apiSettingsViewModel.isContextBuilderProviderValidationComplete else { + return AgentModelCatalog.normalizePersistedSelection( + agentRaw: agentRaw, + modelRaw: modelRaw, + availability: availability, + codexDynamicModels: codexDynamicModels + ) + } return AutoRecommendationEngine.resolveContextBuilderSelection( persistedAgentRaw: agentRaw, persistedModelRaw: modelRaw, - availability: apiSettingsViewModel.contextBuilderRestorationAvailabilityContext, + availability: availability, enabledRecommendationProviders: settingsManager.globalRecommendationProviderFilter() ) } diff --git a/Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel.swift b/Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel.swift index e01db967a..7f89077c4 100644 --- a/Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel.swift +++ b/Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel.swift @@ -488,18 +488,24 @@ class PromptViewModel: ObservableObject { } private func resolvedPersistedContextBuilderSelection() -> AgentModelCatalog.NormalizedAgentSelection? { - guard let apiSettingsViewModel, - apiSettingsViewModel.isContextBuilderProviderValidationComplete - else { + guard let apiSettingsViewModel else { return nil } let profile = currentAgentModelsProfile() let agentRaw = profile.contextBuilderAgentRaw let modelRaw = agentRaw.flatMap { profile.contextBuilderModelsByAgent?[$0] } + let availability = apiSettingsViewModel.contextBuilderRestorationAvailabilityContext + guard apiSettingsViewModel.isContextBuilderProviderValidationComplete else { + return AgentModelCatalog.normalizePersistedSelection( + agentRaw: agentRaw, + modelRaw: modelRaw, + availability: availability + ) + } return AutoRecommendationEngine.resolveContextBuilderSelection( persistedAgentRaw: agentRaw, persistedModelRaw: modelRaw, - availability: apiSettingsViewModel.contextBuilderRestorationAvailabilityContext, + availability: availability, enabledRecommendationProviders: settingsManager.globalRecommendationProviderFilter() ) } diff --git a/Tests/RepoPromptTests/Prompt/GlobalSettingsCrossWindowPropagationTests.swift b/Tests/RepoPromptTests/Prompt/GlobalSettingsCrossWindowPropagationTests.swift index 3bf7de423..a854b0aab 100644 --- a/Tests/RepoPromptTests/Prompt/GlobalSettingsCrossWindowPropagationTests.swift +++ b/Tests/RepoPromptTests/Prompt/GlobalSettingsCrossWindowPropagationTests.swift @@ -75,6 +75,47 @@ final class GlobalSettingsCrossWindowPropagationTests: XCTestCase { XCTAssertEqual(persisted.modelRaw, AgentModel.gpt55CodexLow.rawValue) } + func testGlobalContextBuilderSelectionUpdatesPopoverProjectionAndLaunchAuthority() async throws { + let previousCodexConnected = UserDefaults.standard.object(forKey: "CodexCLIConnected") + defer { + if let previousCodexConnected { + UserDefaults.standard.set(previousCodexConnected, forKey: "CodexCLIConnected") + } else { + UserDefaults.standard.removeObject(forKey: "CodexCLIConnected") + } + } + UserDefaults.standard.set(true, forKey: "CodexCLIConnected") + + let store = try makeIsolatedStore() + let prompt = makePromptViewModel(windowID: 1, store: store) + let profile = AgentModelsSettingsProfile( + contextBuilderAgentRaw: AgentProviderKind.codexExec.rawValue, + contextBuilderModelsByAgent: [ + AgentProviderKind.codexExec.rawValue: AgentModel.gpt55CodexLow.rawValue + ] + ) + + store.setGlobalAgentModelsProfile(profile, contextBuilderWriteIntent: .userInitiated) + await drainMainQueue() + + XCTAssertEqual(prompt.contextBuilderAgent, .codexExec) + XCTAssertEqual(prompt.contextBuilderAgentModelRaw, AgentModel.gpt55CodexLow.rawValue) + + let persisted = store.persistedGlobalContextBuilderAgentSelection() + let resolved = try XCTUnwrap(AutoRecommendationEngine.resolveContextBuilderSelection( + persistedAgentRaw: persisted.agentRaw, + persistedModelRaw: persisted.modelRaw, + availability: .init( + claudeCodeAvailable: true, + codexAvailable: true, + openCodeAvailable: false, + cursorAvailable: false + ) + )) + XCTAssertEqual(resolved.agent, prompt.contextBuilderAgent) + XCTAssertEqual(resolved.modelRaw, prompt.contextBuilderAgentModelRaw) + } + // NOTE: Context Builder agent propagation is exercised compositionally — the store-side // publish is covered by SettingsJSONOnlyPersistenceTests.testGlobalDefaultsSettersPublishObjectWillChange // and the VM-side subscription + re-seed is covered by testOracleModelChangePropagatesAcrossWindows From 12c2f5ddfb1d1d89745c7033e91f3b8044a6c304 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:55:49 +0800 Subject: [PATCH 2/2] fix(context-builder): gate dynamic model polling Follow-up for #561 --- .../Fixtures/test-suite-contract-ledger.tsv | 1 + .../ContextBuilderAgentViewModel.swift | 86 ++++++++++++++---- .../WindowCloseCoordinatorDecisionTests.swift | 90 ++++++++++++++++++- 3 files changed, 155 insertions(+), 22 deletions(-) diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index 2029e1922..ade09454e 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -2496,6 +2496,7 @@ root/RepoPromptTests.WebSearchToolCardTests/testWebActionClassifierDistinguishes root/RepoPromptTests.WebSearchToolCardTests/testWebActionSubtitleFormatting root Tests/RepoPromptTests/AgentMode/ToolCards/WebSearchToolCardTests.swift RepoPromptTests.WebSearchToolCardTests testWebActionSubtitleFormatting AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 163 root/RepoPromptTests.WebSearchToolCardTests/testWebSearchNamesStayDistinctFromFileSearchAndRouteAsKnownResult root Tests/RepoPromptTests/AgentMode/ToolCards/WebSearchToolCardTests.swift RepoPromptTests.WebSearchToolCardTests testWebSearchNamesStayDistinctFromFileSearchAndRouteAsKnownResult AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 5 root/RepoPromptTests.WebSearchToolCardTests/testWebSearchPresentationCoversLiveAndSummaryOnlyPayloads root Tests/RepoPromptTests/AgentMode/ToolCards/WebSearchToolCardTests.swift RepoPromptTests.WebSearchToolCardTests testWebSearchPresentationCoversLiveAndSummaryOnlyPayloads AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 281 +root/RepoPromptTests.WindowCloseCoordinatorLifecycleTests/testPreValidationHydrationStopsExplicitlyStartedDynamicModelPolling root Tests/RepoPromptTests/App/WindowCloseCoordinatorDecisionTests.swift RepoPromptTests.WindowCloseCoordinatorLifecycleTests testPreValidationHydrationStopsExplicitlyStartedDynamicModelPolling ContextBuilder context_builder.dynamic_polling.prevalidation_stale_poller_reconciliation codex,opencode,cursor,polling,validation,lifecycle deterministic_lifecycle root_swiftpm routine 4 shared_global_settings,user_defaults,tracked_window Pre-validation hydration reconciles explicitly started Codex, OpenCode, and Cursor pollers without starting new polling. Stale dynamic pollers can survive pre-validation restoration and leak per-window subscriptions. 0.000000 global_settings,user_defaults window_lifecycle retain 0 Issue #561 stale dynamic polling lifecycle regression root/RepoPromptTests.WindowCloseCoordinatorLifecycleTests/testAPISettingsCloseDuringInitialLoadDoesNotStartProviderValidation root Tests/RepoPromptTests/App/WindowCloseCoordinatorDecisionTests.swift RepoPromptTests.WindowCloseCoordinatorLifecycleTests testAPISettingsCloseDuringInitialLoadDoesNotStartProviderValidation App unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.001000 unreviewed retain_pending_review 0 initial census source line 194 root/RepoPromptTests.WindowCloseCoordinatorLifecycleTests/testSuspendedGitContextRefreshDoesNotRetainViewModel root Tests/RepoPromptTests/App/WindowCloseCoordinatorDecisionTests.swift RepoPromptTests.WindowCloseCoordinatorLifecycleTests testSuspendedGitContextRefreshDoesNotRetainViewModel App unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 60 root/RepoPromptTests.WindowCloseCoordinatorLifecycleTests/testUnregisterDisposesPerWindowCodexModelSubscribersAndStopsOwnedClient root Tests/RepoPromptTests/App/WindowCloseCoordinatorDecisionTests.swift RepoPromptTests.WindowCloseCoordinatorLifecycleTests testUnregisterDisposesPerWindowCodexModelSubscribersAndStopsOwnedClient App unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.207000 unreviewed retain_pending_review 0 initial census source line 117 diff --git a/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift b/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift index 41a6964c3..1e23174fe 100644 --- a/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift +++ b/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift @@ -529,7 +529,7 @@ final class ContextBuilderAgentViewModel: ObservableObject { selectedModel = AgentModel.resolvedModel(forRaw: selectedModelRaw, agentKind: selectedAgent) ?? .defaultModel isRestoringState = false } - updateDynamicModelPolling() + updateDynamicModelPolling(allowUnvalidatedDynamicPolling: true) persistAgentModelToEffectiveProfile() if let session = activeSession { persistSessionConfig(session) @@ -1003,7 +1003,10 @@ final class ContextBuilderAgentViewModel: ObservableObject { guard let self else { return } await handleComposeTabsWillClose(tabIDs) } - updateDynamicModelPolling(startCursorPolling: false) + updateDynamicModelPolling( + startCursorPolling: false, + allowUnvalidatedDynamicPolling: false + ) } func prepareForWindowClose() { @@ -1090,21 +1093,34 @@ final class ContextBuilderAgentViewModel: ObservableObject { private func handleAgentProviderAvailabilityChanged() { refreshAvailableAgents() guard let normalized = resolvedPersistedContextBuilderSelection() else { return } - guard normalized.agent != selectedAgent || normalized.modelRaw.caseInsensitiveCompare(selectedModelRaw) != .orderedSame else { - return + let selectionChanged = normalized.agent != selectedAgent || + normalized.modelRaw.caseInsensitiveCompare(selectedModelRaw) != .orderedSame + if selectionChanged { + isRestoringState = true + selectedAgent = normalized.agent + selectedModelRaw = normalized.modelRaw + selectedModel = AgentModel.resolvedModel(forRaw: normalized.modelRaw, agentKind: normalized.agent) ?? .defaultModel + isRestoringState = false } - isRestoringState = true - selectedAgent = normalized.agent - selectedModelRaw = normalized.modelRaw - selectedModel = AgentModel.resolvedModel(forRaw: normalized.modelRaw, agentKind: normalized.agent) ?? .defaultModel - isRestoringState = false - updateDynamicModelPolling() + updateDynamicModelPolling( + startCursorPolling: false, + allowUnvalidatedDynamicPolling: false + ) } - private func updateDynamicModelPolling(startCursorPolling: Bool = true) { - updateCodexModelPolling() - updateOpenCodeModelPolling() - updateCursorModelPolling(startPolling: startCursorPolling) + private func updateDynamicModelPolling( + startCursorPolling: Bool = true, + allowUnvalidatedDynamicPolling: Bool = false + ) { + if allowUnvalidatedDynamicPolling || promptManager.apiSettingsViewModel?.isContextBuilderProviderValidationComplete == true { + updateCodexModelPolling() + updateOpenCodeModelPolling() + updateCursorModelPolling(startPolling: startCursorPolling) + } else { + stopCodexModelsSubscription() + stopOpenCodeModelsSubscription() + stopCursorModelsSubscription() + } } private func updateCodexModelPolling() { @@ -1145,6 +1161,26 @@ final class ContextBuilderAgentViewModel: ObservableObject { codexModelsSubscriptionTask != nil } + var test_hasCursorModelsSubscriptionTask: Bool { + cursorModelsSubscriptionTask != nil + } + + var test_hasOpenCodeModelsSubscriptionTask: Bool { + openCodeModelsSubscriptionTask != nil + } + + func test_applyEffectiveAgentModelForLifecycle() { + applyEffectiveAgentModel() + } + + func test_handleAgentProviderAvailabilityChangedForLifecycle() { + handleAgentProviderAvailabilityChanged() + } + + func test_updateDynamicModelPollingForLifecycle() { + updateDynamicModelPolling() + } + func test_cancelAndDrainCodexModelsSubscription() async { let task = codexModelsSubscriptionTask task?.cancel() @@ -1359,7 +1395,10 @@ final class ContextBuilderAgentViewModel: ObservableObject { } isRestoringState = false - updateDynamicModelPolling(startCursorPolling: false) + updateDynamicModelPolling( + startCursorPolling: false, + allowUnvalidatedDynamicPolling: false + ) } private func applySessionToBindings(_ session: TabSession) { @@ -1388,7 +1427,10 @@ final class ContextBuilderAgentViewModel: ObservableObject { contextBuilderInstructions = session.contextBuilderInstructions selectedContextBuilderPromptIDs = session.selectedContextBuilderPromptIDs isRestoringState = false - updateDynamicModelPolling(startCursorPolling: false) + updateDynamicModelPolling( + startCursorPolling: false, + allowUnvalidatedDynamicPolling: false + ) } private func clearBindings() { @@ -1428,7 +1470,10 @@ final class ContextBuilderAgentViewModel: ObservableObject { questionTimeoutSeconds = ContextBuilderDefaults.questionTimeoutSeconds planTokenBudget = ContextBuilderDefaults.planTokenBudget isRestoringState = false - updateDynamicModelPolling(startCursorPolling: false) + updateDynamicModelPolling( + startCursorPolling: false, + allowUnvalidatedDynamicPolling: false + ) } private func updateRuntimeBindings(from session: TabSession) { @@ -1455,7 +1500,7 @@ final class ContextBuilderAgentViewModel: ObservableObject { // Per-tab selected follow-up type selectedFollowUpType = session.selectedFollowUpType isRestoringState = false - updateDynamicModelPolling() + updateDynamicModelPolling(allowUnvalidatedDynamicPolling: false) } /// Lightweight binding update for streaming hot path - only updates agentLog and toolCallCount. @@ -1592,7 +1637,10 @@ final class ContextBuilderAgentViewModel: ObservableObject { selectedModel = AgentModel.resolvedModel(forRaw: normalized.modelRaw, agentKind: normalized.agent) ?? .defaultModel isRestoringState = false refreshAvailableAgents() - updateDynamicModelPolling(startCursorPolling: false) + updateDynamicModelPolling( + startCursorPolling: false, + allowUnvalidatedDynamicPolling: false + ) } /// Load workspace-scoped discovery defaults (token budget, enhancement mode, clarifying questions, plan budget). diff --git a/Tests/RepoPromptTests/App/WindowCloseCoordinatorDecisionTests.swift b/Tests/RepoPromptTests/App/WindowCloseCoordinatorDecisionTests.swift index d8443d0f1..926d60d5b 100644 --- a/Tests/RepoPromptTests/App/WindowCloseCoordinatorDecisionTests.swift +++ b/Tests/RepoPromptTests/App/WindowCloseCoordinatorDecisionTests.swift @@ -140,10 +140,12 @@ final class WindowCloseCoordinatorLifecycleTests: XCTestCase { XCTAssertFalse(window.apiSettingsViewModel.test_hasPreparedForWindowClose) window.apiSettingsViewModel.test_startCodexModelsSubscriptionIfNeeded() guard await waitForSubscriberCount(1, pollingService: pollingService) else { return } - // Use Context Builder's product-valid selected-agent path; forcing a Codex - // subscription while the selected agent is non-Codex can be stopped by - // normal model-polling reconciliation before this assertion runs. + // Use Context Builder's product-valid selected-agent path; explicitly + // transition away and back because persisted global hydration may already select Codex. + window.contextBuilderAgentViewModel.selectedAgent = .claudeCode window.contextBuilderAgentViewModel.selectedAgent = .codexExec + await waitForPendingMainQueueWork() + window.contextBuilderAgentViewModel.test_startCodexModelsSubscriptionIfNeeded() XCTAssertTrue(window.contextBuilderAgentViewModel.test_hasCodexModelsSubscriptionTask) guard await waitForSubscriberCount(2, pollingService: pollingService) else { return } let attachedSubscriberCount = await pollingService.test_subscriberCount() @@ -185,6 +187,88 @@ final class WindowCloseCoordinatorLifecycleTests: XCTestCase { XCTAssertEqual(requestObservationAfterClose.activeRequestCount, 0) } + func testPreValidationHydrationStopsExplicitlyStartedDynamicModelPolling() async { + let previousCursorConnected = UserDefaults.standard.object(forKey: "CursorCLIConnected") + let settingsStore = GlobalSettingsStore.shared + let previousProfile = settingsStore.globalAgentModelsProfile() + defer { + settingsStore.setGlobalAgentModelsProfile( + previousProfile, + contextBuilderWriteIntent: .preserveExistingOwnership + ) + if let previousCursorConnected { + UserDefaults.standard.set(previousCursorConnected, forKey: "CursorCLIConnected") + } else { + UserDefaults.standard.removeObject(forKey: "CursorCLIConnected") + } + } + UserDefaults.standard.set(true, forKey: "CursorCLIConnected") + settingsStore.setGlobalAgentModelsProfile( + AgentModelsSettingsProfile( + contextBuilderAgentRaw: AgentProviderKind.cursor.rawValue, + contextBuilderModelsByAgent: [ + AgentProviderKind.cursor.rawValue: AgentModel.cursorAuto.rawValue + ] + ), + contextBuilderWriteIntent: .userInitiated + ) + + let client = WindowClosePollingClientSpy() + let pollingService = CodexModelPollingService( + client: client, + intervalNanos: 60_000_000_000, + stopClientWhenIdle: true + ) + let window = trackWindow(WindowState( + codexModelPollingService: pollingService, + loadStoredAPISettingsDataOnInit: false + )) + WindowStatesManager.shared.registerWindowState(window) + let viewModel = window.contextBuilderAgentViewModel + + func persistClaudeSelection() { + settingsStore.setGlobalAgentModelsProfile( + AgentModelsSettingsProfile( + contextBuilderAgentRaw: AgentProviderKind.claudeCode.rawValue, + contextBuilderModelsByAgent: [ + AgentProviderKind.claudeCode.rawValue: AgentModel.claudeSonnet.rawValue + ] + ), + contextBuilderWriteIntent: .userInitiated + ) + } + + func assertNoDynamicModelPolling() { + XCTAssertFalse(viewModel.test_hasCodexModelsSubscriptionTask) + XCTAssertFalse(viewModel.test_hasOpenCodeModelsSubscriptionTask) + XCTAssertFalse(viewModel.test_hasCursorModelsSubscriptionTask) + } + + XCTAssertFalse(window.apiSettingsViewModel.isContextBuilderProviderValidationComplete) + assertNoDynamicModelPolling() + + viewModel.selectedAgent = .codexExec + XCTAssertTrue(viewModel.test_hasCodexModelsSubscriptionTask) + persistClaudeSelection() + viewModel.test_applyEffectiveAgentModelForLifecycle() + assertNoDynamicModelPolling() + + viewModel.selectedAgent = .openCode + XCTAssertTrue(viewModel.test_hasOpenCodeModelsSubscriptionTask) + persistClaudeSelection() + viewModel.test_handleAgentProviderAvailabilityChangedForLifecycle() + assertNoDynamicModelPolling() + + viewModel.selectedAgent = .cursor + XCTAssertTrue(viewModel.test_hasCursorModelsSubscriptionTask) + persistClaudeSelection() + viewModel.test_applyEffectiveAgentModelForLifecycle() + assertNoDynamicModelPolling() + + unregisterTrackedWindow(window) + await window.tearDown() + } + func testAPISettingsCloseDuringInitialLoadDoesNotStartProviderValidation() async throws { let loadGate = APISettingsInitialLoadGate() let validationProbe = APISettingsProviderValidationProbe()