From 9da4133f19de5df959d64b5cb739262491e466f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 26 May 2026 01:44:01 +0000 Subject: [PATCH 1/2] Fix MemoryContextAssembler.invalidateCache cache-key mismatch The assembler keyed cache entries on '(agentId, toolsAvailable)' but invalidateCache(agentId:) removed only the bare 'agentId' key. The two never matched, so stale 10-second snapshots survived user-visible memory edits and the chatOnly/withTools partition fix from PR #877 could appear to regress on the next compose pass after window changes. Fix invalidateCache to drop both 'tools=0' and 'tools=1' entries for the given agent, and add regression tests using small internal test-only helpers (_seedCache / _hasCachedEntry) so the eviction contract is verifiable without standing up the full MemoryDatabase stack. Co-authored-by: Michael Meding --- .../Memory/MemoryContextAssembler.swift | 29 ++++++++++- .../Tests/Memory/MemoryTests.swift | 49 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) 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 { From 316436e51f136fcd918dfa7075638ceb79aad616 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 05:08:34 +0000 Subject: [PATCH 2/2] Fix flake: skip ModelManager launch-time HF fetch under xctest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModelManager.init kicks off an unstructured Task that calls loadOsaurusAIOrgModels(), which fetches the OsaurusAI organization listing from Hugging Face and feeds the result through applyOsaurusOrgFetch. The unit-test runner repeatedly constructs ModelManager() to drive applyOsaurusOrgFetch directly. The background launch-time fetch races with those test calls — whichever finishes last wins, and the merge result is non-deterministic. That's the root cause of the flaky ModelManagerSuggestedTests failures seen across many of the recent PR CI runs (applyOsaurusOrgFetch_dropsStaleAutoFetched OnReapply, applyOsaurusOrgFetch_addsNewEntriesAfterCurated, etc.). Gate the launch-time fetch on a small isRunningInTestEnvironment helper that checks for any of XCTestConfigurationFilePath, XCTestBundlePath, or XCTestSessionIdentifier in the process environment. Those variables are only present inside an xctest host process; production app launches still get the HF fetch exactly as before. This is a network call, so removing it under tests also has the side benefit of making the test suite work offline / on hermetic CI runners. Co-authored-by: Michael Meding --- .../Managers/Model/ModelManager.swift | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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