Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion Packages/OsaurusCore/Managers/Model/ModelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 27 additions & 2 deletions Packages/OsaurusCore/Services/Memory/MemoryContextAssembler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions Packages/OsaurusCore/Tests/Memory/MemoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading