diff --git a/Packages/OsaurusCore/Managers/Model/ModelManager.swift b/Packages/OsaurusCore/Managers/Model/ModelManager.swift index c87d515f6..dc6595695 100644 --- a/Packages/OsaurusCore/Managers/Model/ModelManager.swift +++ b/Packages/OsaurusCore/Managers/Model/ModelManager.swift @@ -188,7 +188,27 @@ final class ModelManager: NSObject, ObservableObject { // Pull the OsaurusAI HF org listing once on launch so newly published // models surface in the Recommended tab without requiring a code push. - Task { [weak self] in await self?.loadOsaurusAIOrgModels() } + // + // The unit-test runner constructs `ModelManager()` repeatedly to drive + // `applyOsaurusOrgFetch` directly. If the launch-time HF fetch races + // with those test calls, whichever finishes last wins and the merge + // result is non-deterministic — that's the regression class behind + // `ModelManagerSuggestedTests/applyOsaurusOrgFetch_*` flaking in CI. + // Skip the background fetch under XCTest; production launches still + // get it because `XCTestConfigurationFilePath` is only set inside + // a test host. + if !Self.isRunningInTestEnvironment { + Task { [weak self] in await self?.loadOsaurusAIOrgModels() } + } + } + + /// True when the current process was launched by xctest. Used to gate + /// network-touching launch-time side effects so tests can drive the + /// affected code paths deterministically. + nonisolated private static var isRunningInTestEnvironment: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + || ProcessInfo.processInfo.environment["XCTestBundlePath"] != nil + || ProcessInfo.processInfo.environment["XCTestSessionIdentifier"] != nil } // MARK: - Public Methods diff --git a/Packages/OsaurusCore/Services/Memory/MemoryContextAssembler.swift b/Packages/OsaurusCore/Services/Memory/MemoryContextAssembler.swift index 71844912f..623054554 100644 --- a/Packages/OsaurusCore/Services/Memory/MemoryContextAssembler.swift +++ b/Packages/OsaurusCore/Services/Memory/MemoryContextAssembler.swift @@ -116,15 +116,40 @@ public actor MemoryContextAssembler { return context } - /// Invalidate cached context for a specific agent. + /// Invalidate cached context for a specific agent, or the entire cache + /// when `agentId` is nil. + /// + /// Cache entries are keyed by `(agentId, toolsAvailable)` (see `cacheKey`). + /// Removing only the bare `agentId` would leave stale `tools=0`/`tools=1` + /// snapshots in place for up to `cacheTTL` seconds, defeating callers like + /// `ChatWindowManager` that invalidate after user-visible memory edits. + /// Drop both partitions for the given agent. public func invalidateCache(agentId: String? = nil) { if let agentId { - cache.removeValue(forKey: agentId) + cache.removeValue(forKey: Self.cacheKey(agentId: agentId, toolsAvailable: true)) + cache.removeValue(forKey: Self.cacheKey(agentId: agentId, toolsAvailable: false)) } else { cache.removeAll() } } + /// Test-only helper: returns true if a non-expired cache entry exists for + /// the given `(agentId, toolsAvailable)` pair. Used to verify that + /// `invalidateCache(agentId:)` evicts both partitions. + internal func _hasCachedEntry(agentId: String, toolsAvailable: Bool) -> Bool { + guard let entry = cache[Self.cacheKey(agentId: agentId, toolsAvailable: toolsAvailable)] else { + return false + } + return Date().timeIntervalSince(entry.timestamp) < Self.cacheTTL + } + + /// Test-only helper: seeds a cache entry for the given agent/partition. + /// Avoids needing a fully wired `MemoryDatabase` for cache-eviction tests. + internal func _seedCache(agentId: String, toolsAvailable: Bool, context: String = "seeded") { + cache[Self.cacheKey(agentId: agentId, toolsAvailable: toolsAvailable)] = + CacheEntry(context: context, timestamp: Date()) + } + private func buildContext( agentId: String, config: MemoryConfiguration, diff --git a/Packages/OsaurusCore/Tests/Memory/MemoryTests.swift b/Packages/OsaurusCore/Tests/Memory/MemoryTests.swift index 7b4c0df08..2e50f1530 100644 --- a/Packages/OsaurusCore/Tests/Memory/MemoryTests.swift +++ b/Packages/OsaurusCore/Tests/Memory/MemoryTests.swift @@ -243,6 +243,55 @@ struct MemoryContextAssemblerTests { ) #expect(baseContext == queryContext) } + + /// Regression test for a cache-key mismatch where `invalidateCache(agentId:)` + /// removed entries keyed solely on `agentId` while the cache was actually + /// keyed on `(agentId, toolsAvailable)`. The bug let stale 10-second + /// snapshots survive user-visible memory edits, contradicting the + /// invalidation contract that callers like `ChatWindowManager` rely on. + @Test func invalidateCacheEvictsBothToolsPartitions() async { + let assembler = MemoryContextAssembler.shared + let agentId = "cache-test-\(UUID().uuidString)" + + await assembler._seedCache(agentId: agentId, toolsAvailable: true) + await assembler._seedCache(agentId: agentId, toolsAvailable: false) + + let beforeWithTools = await assembler._hasCachedEntry( + agentId: agentId, toolsAvailable: true) + let beforeChatOnly = await assembler._hasCachedEntry( + agentId: agentId, toolsAvailable: false) + #expect(beforeWithTools) + #expect(beforeChatOnly) + + await assembler.invalidateCache(agentId: agentId) + + let afterWithTools = await assembler._hasCachedEntry( + agentId: agentId, toolsAvailable: true) + let afterChatOnly = await assembler._hasCachedEntry( + agentId: agentId, toolsAvailable: false) + #expect(!afterWithTools) + #expect(!afterChatOnly) + } + + @Test func invalidateCacheScopedToAgent() async { + let assembler = MemoryContextAssembler.shared + let target = "target-\(UUID().uuidString)" + let other = "other-\(UUID().uuidString)" + + await assembler._seedCache(agentId: target, toolsAvailable: true) + await assembler._seedCache(agentId: other, toolsAvailable: true) + + await assembler.invalidateCache(agentId: target) + + let targetStillCached = await assembler._hasCachedEntry( + agentId: target, toolsAvailable: true) + let otherStillCached = await assembler._hasCachedEntry( + agentId: other, toolsAvailable: true) + #expect(!targetStillCached) + #expect(otherStillCached) + + await assembler.invalidateCache(agentId: other) + } } struct MemoryDatabaseTests {