Skip to content
Merged
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
34 changes: 19 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,11 @@ jobs:

- name: Restore DerivedData cache
id: dd-cache
# Always restore so `cache-primary-key` is populated for the save
# step at the bottom (the wipe step below handles forced cold
# builds without preventing main from repopulating the cache).
# Restore only on main pushes / manual maintainer runs. Pull requests
# intentionally cold-build DerivedData: restore-key hits have produced
# stale Swift modules whose C-module dependencies are missing when
# Xcode later compiles EventSource.
if: ${{ github.event_name != 'pull_request' }}
uses: actions/cache/restore@v5
with:
path: ~/Library/Developer/Xcode/DerivedData
Expand All @@ -97,13 +99,15 @@ jobs:
restore-keys: |
dd-${{ runner.os }}-${{ env.CACHE_SALT }}-xcode${{ env.XCODE_VERSION }}-

# Make "clear the build cache" a one-click operation. Two triggers:
# 1. `github.run_attempt != '1'` — i.e. a re-run. The default
# Make "clear the build cache" a one-click operation. Three triggers:
# 1. Pull requests — always cold-build DerivedData so PRs never trust
# a cached Xcode build product from another ref.
# 2. `github.run_attempt != '1'` — i.e. a re-run. The default
# "Re-run failed jobs" button is the natural place for someone
# who just saw a build failure to land, so we make that the
# intuitive escape hatch for cache poison: the first attempt
# uses the cache (fast); any re-run forces a cold compile.
# 2. `workflow_dispatch.clear_cache=true` — manual force-cold on
# 3. `workflow_dispatch.clear_cache=true` — manual force-cold on
# a fresh run (e.g. validating a CACHE_SALT bump before PRs
# start hitting it).
#
Expand All @@ -116,18 +120,18 @@ jobs:
# every re-run cost ~2 min in PR #951 run 24937664669 — wasted
# budget that contributed to the 30-min cold-build cancellation.
#
# We wipe AFTER the restore step (rather than skipping the restore)
# so `steps.dd-cache.outputs.cache-primary-key` stays populated and
# the `Save DerivedData cache` step at the bottom can still
# repopulate the cache on a successful `main` run.
- name: Wipe restored DerivedData (re-run or workflow_dispatch clear_cache)
if: ${{ github.run_attempt != '1' || (github.event_name == 'workflow_dispatch' && inputs.clear_cache) }}
# On main/manual runs we wipe AFTER the restore step (rather than
# skipping the restore) so `steps.dd-cache.outputs.cache-primary-key`
# stays populated and the `Save DerivedData cache` step at the bottom
# can still repopulate the cache on a successful `main` run.
- name: Wipe restored DerivedData (PR, re-run, or workflow_dispatch clear_cache)
if: ${{ github.event_name == 'pull_request' || github.run_attempt != '1' || (github.event_name == 'workflow_dispatch' && inputs.clear_cache) }}
run: |
REASON="run_attempt=${{ github.run_attempt }}"
REASON="event=${{ github.event_name }}, run_attempt=${{ github.run_attempt }}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.clear_cache }}" = "true" ]; then
REASON="$REASON, workflow_dispatch clear_cache=true"
fi
echo "::notice title=Cold build forced::Wiping restored DerivedData before build ($REASON). SPM cache preserved (it's source-only and pinned by Package.resolved). To re-run with the warm cache instead, push a new commit or trigger a fresh run."
echo "::notice title=Cold build forced::Wiping DerivedData before build ($REASON). SPM cache preserved (it's source-only and pinned by Package.resolved)."
rm -rf "$HOME/Library/Developer/Xcode/DerivedData"

- name: Resolve dependencies
Expand Down Expand Up @@ -248,7 +252,7 @@ jobs:
echo
echo "**\`run_attempt > 1\` AND \`cache-hit: false\`?** That's the deliberate cold-rebuild path triggered by **Re-run failed jobs** — see the \`Wipe restored DerivedData\` step in this job. If the cold build is exhausting the 45-min budget on every re-run, the codebase has outgrown the budget; bump \`timeout-minutes\` and update its comment block, OR move warm-cache priming to a nightly \`main\` job so PRs always warm-start."
echo
echo "**Suspect cache poisoning on a fresh attempt?** Click **Re-run failed jobs** — re-runs automatically wipe DerivedData (the SPM cache is preserved because it's pinned by \`Package.resolved\` and can't be poisoned)."
echo "**Suspect cache poisoning on a fresh attempt?** Pull requests already cold-build DerivedData; main/manual re-runs wipe DerivedData automatically while preserving the pinned SPM source cache."
} >> "$GITHUB_STEP_SUMMARY"
else
# Mode B.
Expand Down
7 changes: 7 additions & 0 deletions Packages/OsaurusCore/Models/Chat/ChatSessionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,11 @@ enum ChatSessionStore {
}
LegacySessionImporter.runIfNeeded()
}

#if DEBUG
static func _resetForTesting() {
didOpen = false
ChatHistoryDatabase.shared.close()
}
Comment on lines +79 to +82
#endif
}
169 changes: 86 additions & 83 deletions Packages/OsaurusCore/Tests/Chat/ChatSessionResetForAgentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import Testing
@Suite(.serialized)
@MainActor
struct ChatSessionResetForAgentTests {

/// Primary regression pin for #1005. Constructs a session in the same
/// shape `ChatWindowState.loadSession` leaves it in (existing
/// `sessionId`, populated `turns`, agent X), then calls
Expand All @@ -39,62 +38,64 @@ struct ChatSessionResetForAgentTests {
/// observed when users click "New Chat" after navigating to a
/// conversation from a different agent.
@Test("reset(for:) does not retag previous session via stop() side-effect save")
func resetForAgent_savesPreviousSessionUnderOldAgent() async {
let session = ChatSession()
let originalAgentId = UUID()
let originalSessionId = UUID()
let newAgentId = UUID()

// Mimic a freshly-loaded conversation: existing sessionId + turns
// under an existing agent, just like `ChatSession.load(from:)`
// leaves things after `ChatWindowState.loadSession`.
session.agentId = originalAgentId
session.sessionId = originalSessionId
session.turns = [
ChatTurn(role: .user, content: "Hello"),
ChatTurn(role: .assistant, content: "Hi"),
]
session.title = "Existing conversation"

// Capture session state at the moment a save fires.
// `onSessionChanged` is called inside `save()` AFTER
// `toSessionData()` has been encoded and passed to
// `ChatSessionsManager.shared.save`, so the captured snapshot
// reflects what was actually persisted (or attempted to be).
var capturedAgentIdAtSave: UUID?
var capturedSessionIdAtSave: UUID?
var capturedTurnCountAtSave: Int?
session.onSessionChanged = {
capturedAgentIdAtSave = session.agentId
capturedSessionIdAtSave = session.sessionId
capturedTurnCountAtSave = session.turns.count
}

session.reset(for: newAgentId)

// If a save fired during the reset chain, it MUST have targeted
// the original session id under the original agent. Pre-fix this
// would have captured `newAgentId` because `agentId` was
// overwritten before `reset()` ran.
if let capturedAgentId = capturedAgentIdAtSave {
#expect(
capturedSessionIdAtSave == originalSessionId,
"save during reset(for:) should target the original session id"
)
#expect(
capturedAgentId == originalAgentId,
"reset(for:) re-tagged session \(originalSessionId) to agent \(capturedAgentId) instead of preserving \(originalAgentId) (#1005)"
)
#expect(
(capturedTurnCountAtSave ?? 0) > 0,
"save during reset(for:) should still see the original turns"
)
func resetForAgent_savesPreviousSessionUnderOldAgent() async throws {
try await ChatHistoryTestStorage.run {
let session = ChatSession()
let originalAgentId = UUID()
let originalSessionId = UUID()
let newAgentId = UUID()

// Mimic a freshly-loaded conversation: existing sessionId + turns
// under an existing agent, just like `ChatSession.load(from:)`
// leaves things after `ChatWindowState.loadSession`.
session.agentId = originalAgentId
session.sessionId = originalSessionId
session.turns = [
ChatTurn(role: .user, content: "Hello"),
ChatTurn(role: .assistant, content: "Hi"),
]
session.title = "Existing conversation"

// Capture session state at the moment a save fires.
// `onSessionChanged` is called inside `save()` AFTER
// `toSessionData()` has been encoded and passed to
// `ChatSessionsManager.shared.save`, so the captured snapshot
// reflects what was actually persisted (or attempted to be).
var capturedAgentIdAtSave: UUID?
var capturedSessionIdAtSave: UUID?
var capturedTurnCountAtSave: Int?
session.onSessionChanged = {
capturedAgentIdAtSave = session.agentId
capturedSessionIdAtSave = session.sessionId
capturedTurnCountAtSave = session.turns.count
}

session.reset(for: newAgentId)

// If a save fired during the reset chain, it MUST have targeted
// the original session id under the original agent. Pre-fix this
// would have captured `newAgentId` because `agentId` was
// overwritten before `reset()` ran.
if let capturedAgentId = capturedAgentIdAtSave {
#expect(
capturedSessionIdAtSave == originalSessionId,
"save during reset(for:) should target the original session id"
)
#expect(
capturedAgentId == originalAgentId,
"reset(for:) re-tagged session \(originalSessionId) to agent \(capturedAgentId) instead of preserving \(originalAgentId) (#1005)"
)
#expect(
(capturedTurnCountAtSave ?? 0) > 0,
"save during reset(for:) should still see the original turns"
)
}

// Final state: new agent applied, session cleared.
#expect(session.agentId == newAgentId)
#expect(session.sessionId == nil)
#expect(session.turns.isEmpty)
}

// Final state: new agent applied, session cleared.
#expect(session.agentId == newAgentId)
#expect(session.sessionId == nil)
#expect(session.turns.isEmpty)
}

/// Same shape as the regression test above, but also asserts that
Expand All @@ -103,33 +104,35 @@ struct ChatSessionResetForAgentTests {
/// onSessionChanged firing order without fixing the underlying
/// `toSessionData()` payload.
@Test("save() during reset(for:) encodes old agentId in ChatSessionData")
func resetForAgent_toSessionDataInsideSavePreservesOldAgent() async {
let session = ChatSession()
let originalAgentId = UUID()
let originalSessionId = UUID()
let newAgentId = UUID()

session.agentId = originalAgentId
session.sessionId = originalSessionId
session.turns = [ChatTurn(role: .user, content: "single turn")]
session.title = "Loaded chat"

var snapshot: ChatSessionData?
session.onSessionChanged = {
// Re-derive the persisted shape from the same source
// `save()` used. If the agent had already been swapped at
// this point, the snapshot would carry `newAgentId`.
snapshot = session.toSessionData()
}

session.reset(for: newAgentId)

if let snapshot {
#expect(snapshot.id == originalSessionId)
#expect(
snapshot.agentId == originalAgentId,
"ChatSessionData built during reset(for:) carries wrong agent id (#1005)"
)
func resetForAgent_toSessionDataInsideSavePreservesOldAgent() async throws {
try await ChatHistoryTestStorage.run {
let session = ChatSession()
let originalAgentId = UUID()
let originalSessionId = UUID()
let newAgentId = UUID()

session.agentId = originalAgentId
session.sessionId = originalSessionId
session.turns = [ChatTurn(role: .user, content: "single turn")]
session.title = "Loaded chat"

var snapshot: ChatSessionData?
session.onSessionChanged = {
// Re-derive the persisted shape from the same source
// `save()` used. If the agent had already been swapped at
// this point, the snapshot would carry `newAgentId`.
snapshot = session.toSessionData()
}

session.reset(for: newAgentId)

if let snapshot {
#expect(snapshot.id == originalSessionId)
#expect(
snapshot.agentId == originalAgentId,
"ChatSessionData built during reset(for:) carries wrong agent id (#1005)"
)
}
}
}

Expand Down
46 changes: 25 additions & 21 deletions Packages/OsaurusCore/Tests/Chat/ChatSessionStopTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,39 @@ import Testing
@MainActor
struct ChatSessionStopTests {
@Test
func stop_trimsTrailingEmptyAssistantPlaceholder() {
let session = ChatSession()
session.turns = [
ChatTurn(role: .user, content: "Hello"),
ChatTurn(role: .assistant, content: ""),
]

session.stop()

#expect(session.turns.count == 1)
#expect(session.turns.last?.role == .user)
func stop_trimsTrailingEmptyAssistantPlaceholder() async throws {
try await ChatHistoryTestStorage.run {
let session = ChatSession()
session.turns = [
ChatTurn(role: .user, content: "Hello"),
ChatTurn(role: .assistant, content: ""),
]

session.stop()

#expect(session.turns.count == 1)
#expect(session.turns.last?.role == .user)
}
}

@Test
func stop_ignoresLateResultsWhenEngineSetupIgnoresCancellation() async throws {
let session = ChatSession()
session.chatEngineFactory = { IgnoringCancellationChatEngine() }
try await ChatHistoryTestStorage.run {
let session = ChatSession()
session.chatEngineFactory = { IgnoringCancellationChatEngine() }

session.send("Hello")
try await Task.sleep(for: .milliseconds(20))
session.stop()
session.send("Hello")
try await Task.sleep(for: .milliseconds(20))
session.stop()

#expect(session.isStreaming == false)
#expect(session.isStreaming == false)

try await Task.sleep(for: .milliseconds(250))
try await Task.sleep(for: .milliseconds(250))

#expect(session.turns.count == 1)
#expect(session.turns.first?.role == .user)
#expect(session.turns.first?.content == "Hello")
#expect(session.turns.count == 1)
#expect(session.turns.first?.role == .user)
#expect(session.turns.first?.content == "Hello")
}
}
}

Expand Down
Loading
Loading