Files
nucleic/Tests/NucleicCoreTests/ContextSwitchTests.swift
T

751 lines
34 KiB
Swift

import Foundation
import Testing
@testable import NucleicCore
private struct FixedPurposeModel: PurposeModelClassifying {
var verdict: PurposeVerdict?
func classify(prompt: String) async -> PurposeVerdict? { verdict }
}
private actor ContextSwitchHandoverGate {
private var released = false
private var continuation: CheckedContinuation<Void, Never>?
func wait() async {
guard !released else { return }
await withCheckedContinuation { continuation = $0 }
}
func release() {
released = true
continuation?.resume()
continuation = nil
}
}
private enum ContextSwitchFixtures {
struct Turn: Decodable {
var name: String
var routedPurpose: String
var prompt: String
var reply: String
var expectedPurpose: String?
}
static func turns() throws -> [Turn] {
guard let url = Bundle.module.url(
forResource: "context-switch-turns", withExtension: "json",
subdirectory: "Fixtures")
else { fatalError("missing fixture context-switch-turns.json") }
return try JSONDecoder().decode([Turn].self, from: Data(contentsOf: url))
}
}
@Suite("Context Switch — offer policy")
struct ContextSwitchTests {
private let now = Date(timeIntervalSince1970: 1_800_000_000)
private func session(
id: SessionID = .generate(), status: SessionStatus = .awaitingInput,
backend: BackendID = .codex, model: String? = "gpt-5.6-sol",
effort: String? = "xhigh", purpose: PromptPurpose? = .backendImpl,
level: IntelligenceLevel? = .deep
) -> Session {
Session(
id: id, projectID: .generate(), backend: backend,
title: "Context switch test", status: status, model: model, effort: effort,
routedPurpose: purpose?.rawValue, routedLevel: level?.rawValue,
routedReason: purpose.map { "\($0.displayName) · \((level ?? .balanced).displayName)" },
transcriptPath: "/tmp/context-switch-test.jsonl",
createdAt: now, updatedAt: now)
}
private func routing(
connected: Set<BackendID> = [.claudeCode],
limits: IntelligenceRouter.Limits = .init(),
fallbackModel: String = "claude-opus-5", fallbackEffort: String = "high"
) -> ContextSwitchRoutingContext {
ContextSwitchRoutingContext(
connected: connected, limits: limits,
fallbackModel: fallbackModel, fallbackEffort: fallbackEffort)
}
private func verdict(
_ purpose: PromptPurpose = .frontendImpl,
confidence: PurposeVerdict.Confidence = .high
) -> PurposeVerdict {
PurposeVerdict(
purpose: purpose, confidence: confidence, source: .heuristic,
reason: "test signal")
}
private func evaluate(
_ session: Session, verdict: PurposeVerdict? = nil,
routing: ContextSwitchRoutingContext? = nil,
throttle: ContextSwitchThrottle = .init(), enabled: Bool = true,
controllerIsIdle: Bool = true, hasPendingApproval: Bool = false,
currentUserTurn: UInt64 = 4
) -> ContextSwitchEvaluation {
ContextSwitchEvaluator.evaluate(
session: session, verdict: verdict ?? self.verdict(),
trigger: .draft("Build the settings screen and SwiftUI toolbar"),
routing: routing ?? self.routing(), throttle: throttle,
currentUserTurn: currentUserTurn, now: now, enabled: enabled,
controllerIsIdle: controllerIsIdle, hasPendingApproval: hasPendingApproval,
offerID: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!)
}
private func offered(_ evaluation: ContextSwitchEvaluation) -> ContextSwitchOffer? {
guard case .offer(let offer) = evaluation else { return nil }
return offer
}
@Test func highConfidenceDriftResolvesAcrossBackendLanes() throws {
let chat = session()
let offer = try #require(offered(evaluate(chat)))
#expect(offer.sessionID == chat.id)
#expect(offer.fromPurpose == .backendImpl)
#expect(offer.toPurpose == .frontendImpl)
#expect(offer.level == .deep)
#expect(offer.resolution.model == "claude-opus-5")
#expect(offer.resolution.effort == "high")
#expect(offer.targetBackend == .claudeCode)
#expect(offer.phase == .offered)
#expect(offer.identity == .init(toPurpose: .frontendImpl, model: "claude-opus-5"))
#expect(offer.verdict.reason == "test signal")
#expect(offer.trigger == .draft("Build the settings screen and SwiftUI toolbar"))
}
@Test func preflightRejectsEveryIneligibleSessionShape() {
var manual = session()
manual.routedPurpose = nil
manual.routedLevel = nil
manual.routedReason = nil
#expect(ContextSwitchEvaluator.preflight(
session: manual, enabled: true, controllerIsIdle: true,
hasPendingApproval: false) == .missingRoutingNote)
var orchestra = session()
orchestra.effort = OrchestrationMode.effortSentinel
#expect(ContextSwitchEvaluator.preflight(
session: orchestra, enabled: true, controllerIsIdle: true,
hasPendingApproval: false) == .orchestra)
var child = session()
child.spawnedBySessionID = .generate()
#expect(ContextSwitchEvaluator.preflight(
session: child, enabled: true, controllerIsIdle: true,
hasPendingApproval: false) == .spawnedSession)
#expect(ContextSwitchEvaluator.preflight(
session: session(status: .running), enabled: true, controllerIsIdle: true,
hasPendingApproval: false) == .sessionBusy)
#expect(ContextSwitchEvaluator.preflight(
session: session(), enabled: true, controllerIsIdle: false,
hasPendingApproval: false) == .sessionBusy)
#expect(ContextSwitchEvaluator.preflight(
session: session(status: .finished), enabled: true, controllerIsIdle: true,
hasPendingApproval: false) == .sessionNotAwaitingInput(status: .finished))
#expect(ContextSwitchEvaluator.preflight(
session: session(), enabled: true, controllerIsIdle: true,
hasPendingApproval: true) == .pendingApproval)
var muted = session()
muted.contextSwitchMuted = true
#expect(ContextSwitchEvaluator.preflight(
session: muted, enabled: true, controllerIsIdle: true,
hasPendingApproval: false) == .muted)
#expect(ContextSwitchEvaluator.preflight(
session: session(), enabled: false, controllerIsIdle: true,
hasPendingApproval: false) == .featureDisabled)
}
@Test func onlyHighClassifiableDivergenceCanOffer() {
let chat = session()
#expect(evaluate(chat, verdict: verdict(confidence: .medium))
== .ineligible(.insufficientConfidence))
#expect(evaluate(chat, verdict: verdict(.general))
== .ineligible(.unclassifiablePurpose))
#expect(evaluate(chat, verdict: verdict(.backendImpl))
== .ineligible(.target(.samePurpose)))
}
@Test func sameModelAndQuotaBlockedRoutesAreSuppressed() {
let chat = session()
let codexOnly = routing(
connected: [.codex], fallbackModel: "gpt-5.6-sol", fallbackEffort: "xhigh")
#expect(evaluate(chat, routing: codexOnly) == .ineligible(.target(.sameModel)))
let claudeBlocked = routing(
connected: [.claudeCode],
limits: IntelligenceRouter.Limits(providers: [.claudeCode]))
guard case .ineligible(.target(.unavailable(let reason))) =
evaluate(chat, routing: claudeBlocked)
else {
Issue.record("expected a quota-blocked target")
return
}
#expect(reason.contains("usage limit"))
}
@Test func fallbackEffortIsClampedAndUnknownFallbackBackendsAreRejected() throws {
let chat = session(model: "claude-opus-5")
let fallback = routing(
connected: [], fallbackModel: "gpt-5.5-codex", fallbackEffort: "max")
let offer = try #require(offered(evaluate(chat, routing: fallback)))
#expect(offer.resolution.model == "gpt-5.5-codex")
#expect(offer.resolution.effort == "xhigh")
#expect(offer.targetBackend == .codex)
let unknown = routing(
connected: [], fallbackModel: "mystery-model", fallbackEffort: "high")
#expect(evaluate(chat, routing: unknown)
== .ineligible(.target(.unknownBackend(model: "mystery-model"))))
}
@Test func acceptTimeResolutionUsesTheLatestProviderState() throws {
let chat = session()
let initial = ContextSwitchTargetResolver.resolve(
session: chat, detectedPurpose: .frontendImpl,
routing: routing(connected: [.claudeCode]))
let initialTarget = try initial.get()
#expect(initialTarget.backend == .claudeCode)
#expect(initialTarget.resolution.model == "claude-opus-5")
let movedQuota = ContextSwitchTargetResolver.resolve(
session: chat, detectedPurpose: .frontendImpl,
routing: routing(
connected: [.codex], fallbackModel: "gpt-5.6-sol",
fallbackEffort: "xhigh"))
guard case .failure(.sameModel) = movedQuota else {
Issue.record("accept-time resolution should reject the now-redundant switch")
return
}
}
@Test func fullDeclineExpiresOnlyAfterBothThresholds() throws {
let offer = try #require(offered(evaluate(session())))
var throttle = ContextSwitchThrottle()
throttle.decline(offer, atUserTurn: 5, now: now)
#expect(throttle.isSuppressed(
offer, atUserTurn: 14, now: now.addingTimeInterval(21 * 60)))
#expect(throttle.isSuppressed(
offer, atUserTurn: 15, now: now.addingTimeInterval(19 * 60)))
#expect(!throttle.isSuppressed(
offer, atUserTurn: 15, now: now.addingTimeInterval(20 * 60)))
}
@Test func throttleIsIdentityAndSessionScoped() throws {
let firstSession = session()
let offer = try #require(offered(evaluate(firstSession)))
let otherIdentity = ContextSwitchOffer.Identity(
toPurpose: .writing, model: offer.resolution.model)
let otherSession = SessionID.generate()
var throttle = ContextSwitchThrottle()
throttle.decline(offer, atUserTurn: 1, now: now)
#expect(throttle.isSuppressed(offer, atUserTurn: 1, now: now))
#expect(!throttle.isSuppressed(
sessionID: firstSession.id, identity: otherIdentity,
atUserTurn: 1, now: now))
#expect(!throttle.isSuppressed(
sessionID: otherSession, identity: offer.identity,
atUserTurn: 1, now: now))
let reevaluated = evaluate(
firstSession, throttle: throttle, currentUserTurn: 1)
#expect(reevaluated == .ineligible(.cooldown))
}
@Test func implicitDeclineUsesShortTurnOnlyCooldown() throws {
let offer = try #require(offered(evaluate(session())))
var throttle = ContextSwitchThrottle()
throttle.implicitlyDecline(offer, atUserTurn: 20)
#expect(throttle.isSuppressed(
offer, atUserTurn: 21, now: now.addingTimeInterval(24 * 60 * 60)))
#expect(!throttle.isSuppressed(
offer, atUserTurn: 22, now: now.addingTimeInterval(1)))
}
@Test func acceptClearsOnlyThatSessionsCooldownsAndRebaselineStopsTheOldOffer() throws {
var firstSession = session()
let firstOffer = try #require(offered(evaluate(firstSession)))
let secondSession = session()
let secondOffer = try #require(offered(evaluate(secondSession)))
var throttle = ContextSwitchThrottle()
throttle.decline(firstOffer, atUserTurn: 1, now: now)
throttle.decline(secondOffer, atUserTurn: 1, now: now)
throttle.accept(firstOffer)
#expect(!throttle.isSuppressed(firstOffer, atUserTurn: 1, now: now))
#expect(throttle.isSuppressed(secondOffer, atUserTurn: 1, now: now))
firstSession.routedPurpose = PromptPurpose.frontendImpl.rawValue
firstSession.routedReason = "context-switched from backendImpl: frontend"
#expect(evaluate(firstSession, throttle: throttle)
== .ineligible(.target(.samePurpose)))
}
@Test func offerIdentityChangesOnlyWithPurposeOrResolvedModel() throws {
let chat = session()
let original = try #require(offered(evaluate(chat)))
let repeated = try #require(offered(evaluate(
chat,
verdict: PurposeVerdict(
purpose: .frontendImpl, confidence: .high, source: .custom,
reason: "different classifier source"))))
#expect(original.identity == repeated.identity)
let changed = try #require(offered(evaluate(
chat, verdict: verdict(.writing),
routing: routing(
connected: [.grok], fallbackModel: "grok-4.5",
fallbackEffort: "high"))))
#expect(original.identity != changed.identity)
}
@Test func turnRuleMakesPromptWinAndRejectsContradictoryOrQuestionReplies() {
let routed = verdict(.backendImpl)
let frontend = verdict(.frontendImpl)
let writing = verdict(.writing)
#expect(ContextSwitchTurnVerdict.select(
prompt: frontend, reply: writing, routedPurpose: .backendImpl,
replyEndsInQuestion: false)?.purpose == .frontendImpl)
#expect(ContextSwitchTurnVerdict.select(
prompt: routed, reply: frontend, routedPurpose: .backendImpl,
replyEndsInQuestion: false)?.purpose == .frontendImpl)
#expect(ContextSwitchTurnVerdict.select(
prompt: PurposeVerdict(
purpose: .review, confidence: .medium, source: .custom, reason: "review"),
reply: frontend, routedPurpose: .backendImpl,
replyEndsInQuestion: false) == nil)
#expect(ContextSwitchTurnVerdict.select(
prompt: routed, reply: frontend, routedPurpose: .backendImpl,
replyEndsInQuestion: true) == nil)
#expect(ContextSwitchTurnVerdict.select(
prompt: routed, reply: verdict(.frontendImpl, confidence: .medium),
routedPurpose: .backendImpl, replyEndsInQuestion: false) == nil)
}
@Test func replyProseStripsFencedCodeAndKeepsTailLanguage() throws {
let reply = """
The service migration is complete.
```swift
struct SettingsView: View {
var body: some View { Button("UI toolbar layout") {} }
}
```
Next I will update the API endpoint schema and server cache.
"""
let prose = try #require(HeuristicSummary.contextSwitchReplyProse(reply))
#expect(prose.contains("service migration"))
#expect(prose.contains("API endpoint schema"))
#expect(!prose.contains("SettingsView"))
#expect(!prose.contains("toolbar layout"))
#expect(HeuristicPurposeClassifier.classify(prose).purpose == .backendImpl)
let long = String(repeating: "old context words ", count: 100)
+ "Now build the SwiftUI settings view, toolbar, and screen layout."
let tail = try #require(HeuristicSummary.contextSwitchReplyProse(
long, characterLimit: 100))
#expect(tail.contains("SwiftUI settings view"))
#expect(tail.count <= 100)
let unclosed = "Summary prose.\n```swift\nButton(\"UI view layout\")"
#expect(HeuristicSummary.contextSwitchReplyProse(unclosed) == "Summary prose.")
}
@Test func turnTextIgnoresToolPayloadsAndMarksReplyQuestions() throws {
let sid = SessionID(rawValue: "context-turn")
func event(_ seq: UInt64, _ kind: AgentEvent.Kind) -> AgentEvent {
AgentEvent(
sessionID: sid, seq: seq, at: now, backend: .codex,
nativeType: nil, kind: kind)
}
let events = [
event(1, .userText(TextChunk(
messageID: "u", text: "Finish the API endpoint and database schema",
isPartial: false))),
event(2, .toolResult(ToolResult(
toolCallID: "t", content: .string(
"SwiftUI SettingsView Button toolbar screen layout animation"),
isError: false))),
event(3, .assistantText(TextChunk(
messageID: "a", text: "The backend is done. Should I update the UI next?",
isPartial: false))),
]
let text = try #require(HeuristicSummary.contextSwitchTurnText(events))
#expect(text.request.contains("database schema"))
#expect(text.replyProse == "The backend is done. Should I update the UI next?")
#expect(text.replyEndsInQuestion)
#expect(text.replyProse?.contains("SettingsView") == false)
}
@Test func contextSwitchClassifierUsesOnlyHeuristicAndBundledModel() async {
let modelVerdict = PurposeVerdict(
purpose: .frontendImpl, confidence: .high, source: .custom,
reason: "bundled model")
let result = await ContextSwitchPurposeClassifier.classify(
"Please handle this substantial but deliberately keyword-neutral request",
modelClassifier: FixedPurposeModel(verdict: modelVerdict))
#expect(result == modelVerdict)
let heuristic = await ContextSwitchPurposeClassifier.classify(
"Build the API endpoint and database schema",
modelClassifier: FixedPurposeModel(verdict: verdict(.writing)))
#expect(heuristic.purpose == .backendImpl)
#expect(heuristic.source == .heuristic)
}
@Test func settledTurnFixturesExercisePromptReplyAndCodeRules() async throws {
for fixture in try ContextSwitchFixtures.turns() {
let routed = try #require(PromptPurpose(rawValue: fixture.routedPurpose))
let prose = HeuristicSummary.contextSwitchReplyProse(fixture.reply)
let prompt = await ContextSwitchPurposeClassifier.classify(
fixture.prompt, modelClassifier: nil)
let reply: PurposeVerdict?
if let prose {
reply = await ContextSwitchPurposeClassifier.classify(
prose, modelClassifier: nil)
} else {
reply = nil
}
let selected = ContextSwitchTurnVerdict.select(
prompt: prompt, reply: reply, routedPurpose: routed,
replyEndsInQuestion: prose?.hasSuffix("?") == true)
#expect(
selected?.purpose.rawValue == fixture.expectedPurpose,
"fixture: \(fixture.name)")
}
}
}
@MainActor
@Suite(
"Context Switch — AppStore turn-end hook", .serialized,
.isolatedContainerSettings)
struct ContextSwitchAppStoreTests {
@Test(.timeLimit(.minutes(1)))
func settledTurnPublishesOfferForItsSession() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let root = repo.root
let transcripts = URL(fileURLWithPath: repo.container)
.appendingPathComponent("transcripts")
let now: @Sendable () -> Date = {
Date(timeIntervalSince1970: 1_800_000_000)
}
let store = AppStore(
database: try GRDBMetadataStore(path: nil),
worktrees: GitWorktreeManager(now: now),
transcriptsDir: transcripts,
now: now
) { _ in
ScriptedBackend { emitter, _ in
emitter.emit(.sessionStarted(SessionStarted(
backendSessionID: "context-switch-source", model: "gpt-5.6-sol",
cwd: root, toolNames: [])))
emitter.emit(.assistantText(TextChunk(
messageID: "reply",
text: "The API endpoint is complete. Next I will build the SwiftUI settings "
+ "screen, toolbar, and view layout.",
isPartial: false)))
emitter.emit(.turnCompleted(TurnCompleted(
stopReason: "end_turn", usage: nil)))
emitter.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
store.contextSwitchEnabled = true
store.defaultModel = "claude-opus-5"
store.defaultEffort = "high"
let project = try #require(await store.addProject(
name: "context-switch", rootPath: root, defaultBranch: "main"))
let sessionID = try await store.createSession(
in: project, title: "backend work",
prompt: "Finish the API endpoint and database schema",
model: "gpt-5.6-sol", effort: "xhigh",
routing: RoutingNote(
purpose: .backendImpl, level: .deep, reason: "Backend · Deep"),
useWorktree: false)
store.openSessionID = sessionID
await store.awaitOpenSessionSettled()
let offer = try #require(store.contextSwitchOffer(for: sessionID))
#expect(store.openContextSwitchOffer?.id == offer.id)
#expect(offer.fromPurpose == .backendImpl)
#expect(offer.toPurpose == .frontendImpl)
#expect(offer.resolution.model == "claude-opus-5")
guard case .turnEnd(let seq) = offer.trigger else {
Issue.record("turn-end hook produced a draft offer")
return
}
#expect(seq > 0)
let draft = "Build the SwiftUI settings screen, toolbar, and responsive view layout"
let draftVerdict = PurposeVerdict(
purpose: .frontendImpl, confidence: .high, source: .heuristic,
reason: "draft UI signal")
await store.contextSwitchDraftChanged(draft, verdict: draftVerdict, for: sessionID)
let refreshed = try #require(store.contextSwitchOffer(for: sessionID))
#expect(refreshed.id == offer.id) // same identity refreshes without card churn
#expect(refreshed.trigger == .draft(draft))
#expect(refreshed.verdict == draftVerdict)
let routedVerdict = PurposeVerdict(
purpose: .backendImpl, confidence: .high, source: .heuristic,
reason: "returned to the routed work")
await store.contextSwitchDraftChanged(
"Continue implementing the API endpoint and database schema",
verdict: routedVerdict,
for: sessionID)
#expect(store.contextSwitchOffer(for: sessionID) == nil)
#expect(store.openContextSwitchOffer == nil)
// The composer clears optimistically on Switch. Even an accept-time failure (the kill
// switch moved here) must retain that exact draft until dismissal restores it.
await store.contextSwitchDraftChanged(draft, verdict: draftVerdict, for: sessionID)
let failingOffer = try #require(store.contextSwitchOffer(for: sessionID))
store.contextSwitchEnabled = false
await store.acceptContextSwitch(
failingOffer.id, for: sessionID, pendingDraft: draft)
guard case .failed = store.contextSwitchOffer(for: sessionID)?.phase else {
Issue.record("accept-time failure did not leave a failed card")
return
}
#expect(store.contextSwitchStashedDraft(for: sessionID) == draft)
#expect(await store.cancelContextSwitchPreparation(
failingOffer.id, for: sessionID) == draft)
}
@Test(.timeLimit(.minutes(1)))
func disabledCanaryLogsWouldHaveOfferedWithoutPublishingACard() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let root = repo.root
let store = AppStore(
database: try GRDBMetadataStore(path: nil),
worktrees: GitWorktreeManager(),
transcriptsDir: URL(fileURLWithPath: repo.container)
.appendingPathComponent("transcripts-canary")
) { _ in
ScriptedBackend { emitter, _ in
emitter.emit(.sessionStarted(SessionStarted(
backendSessionID: "canary-source", model: "gpt-5.6-sol",
cwd: root, toolNames: [])))
emitter.emit(.assistantText(TextChunk(
messageID: "reply",
text: "The API is complete. Next I will build the SwiftUI settings screen, "
+ "toolbar, and view layout.",
isPartial: false)))
emitter.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
store.contextSwitchEnabled = false
store.defaultModel = "claude-opus-5"
store.defaultEffort = "high"
let initialCanaryCount = store.contextSwitchCanaryEvents.count
let project = try #require(await store.addProject(
name: "context-switch-canary", rootPath: root, defaultBranch: "main"))
let sessionID = try await store.createSession(
in: project, title: "backend work",
prompt: "Finish the API endpoint and database schema",
model: "gpt-5.6-sol", effort: "xhigh",
routing: RoutingNote(
purpose: .backendImpl, level: .deep, reason: "Backend · Deep"),
useWorktree: false)
store.openSessionID = sessionID
await store.awaitOpenSessionSettled()
#expect(store.contextSwitchOffer(for: sessionID) == nil)
let turnEvent = try #require(store.contextSwitchCanaryEvents.last)
#expect(store.contextSwitchCanaryEvents.count == initialCanaryCount + 1)
#expect(turnEvent.trigger == .turnEnd)
#expect(turnEvent.fromPurpose == PromptPurpose.backendImpl.rawValue)
#expect(turnEvent.toPurpose == PromptPurpose.frontendImpl.rawValue)
#expect(turnEvent.model == "claude-opus-5")
#expect(turnEvent.confidence == PurposeVerdict.Confidence.high.rawValue)
// Draft edits with the same offer identity are one canary situation, not one log line per
// debounce. Disabled remains classifier-eligible while still publishing no card.
#expect(await store.contextSwitchDraftIsEligible(sessionID))
let draft = "Build the SwiftUI settings screen, toolbar, and responsive view layout"
let verdict = PurposeVerdict(
purpose: .frontendImpl, confidence: .high, source: .heuristic,
reason: "draft UI signal")
await store.contextSwitchDraftChanged(draft, verdict: verdict, for: sessionID)
await store.contextSwitchDraftChanged(draft + " now", verdict: verdict, for: sessionID)
#expect(store.contextSwitchOffer(for: sessionID) == nil)
#expect(store.contextSwitchCanaryEvents.count == initialCanaryCount + 2)
#expect(store.contextSwitchCanaryEvents.last?.trigger == .draft)
}
@Test(.timeLimit(.minutes(1)))
func acceptingOfferWritesBriefSwapsEngineAndStartsFreshTurn() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let root = repo.root
let transcripts = URL(fileURLWithPath: repo.container)
.appendingPathComponent("transcripts-accept")
let oldTurns = LockedBox(0)
let oldBackend = ScriptedBackend { emitter, _ in
let turn = oldTurns.get() + 1
oldTurns.set(turn)
emitter.emit(.sessionStarted(SessionStarted(
backendSessionID: "old-native", model: "gpt-5.6-sol", cwd: root,
toolNames: [])))
let reply = turn == 1
? "The API endpoint is complete."
: "# Handover\n\nThe API is complete. Build the settings UI next."
emitter.emit(.assistantText(TextChunk(
messageID: "old-\(turn)", text: reply, isPartial: false)))
emitter.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
emitter.emit(.runFinished(RunFinished(outcome: .completed)))
}
let newBackend = ScriptedBackend { emitter, _ in
emitter.emit(.sessionStarted(SessionStarted(
backendSessionID: "new-native", model: "claude-opus-5", cwd: root,
toolNames: [])))
emitter.emit(.assistantText(TextChunk(
messageID: "new", text: "The settings UI is underway.", isPartial: false)))
emitter.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
emitter.emit(.runFinished(RunFinished(outcome: .completed)))
}
let store = AppStore(
database: try GRDBMetadataStore(path: nil),
worktrees: GitWorktreeManager(),
transcriptsDir: transcripts
) { session in
session.backend == .codex ? oldBackend : newBackend
}
store.contextSwitchEnabled = true
store.defaultModel = "claude-opus-5"
store.defaultEffort = "high"
let project = try #require(await store.addProject(
name: "context-switch-accept", rootPath: root, defaultBranch: "main"))
let sessionID = try await store.createSession(
in: project, title: "backend work", prompt: "Implement the API endpoint",
model: "gpt-5.6-sol", effort: "xhigh",
routing: RoutingNote(
purpose: .backendImpl, level: .deep, reason: "Backend · Deep"),
useWorktree: false)
store.openSessionID = sessionID
await store.awaitOpenSessionSettled()
let draft = "Build the SwiftUI settings screen and toolbar layout"
await store.contextSwitchDraftChanged(
draft,
verdict: PurposeVerdict(
purpose: .frontendImpl, confidence: .high, source: .heuristic,
reason: "draft UI signal"),
for: sessionID)
let offer = try #require(store.contextSwitchOffer(for: sessionID))
await store.acceptContextSwitch(offer.id, for: sessionID, pendingDraft: draft)
await store.awaitOpenSessionSettled()
#expect(oldBackend.shutdownCount == 1)
#expect(oldBackend.lastResume?.prompt?.plainText?.contains(
"Write a complete handover brief") == true)
let firstNewRun = try #require(newBackend.lastRun)
#expect(newBackend.lastResume == nil)
#expect(firstNewRun.model == "claude-opus-5")
#expect(firstNewRun.prompt.plainText?.contains("The API is complete") == true)
#expect(firstNewRun.prompt.plainText?.contains(draft) == true)
#expect(store.openSession?.backend == .claudeCode)
#expect(store.openSession?.routedPurpose == PromptPurpose.frontendImpl.rawValue)
#expect(store.contextSwitchOffer(for: sessionID) == nil)
let visibleDrafts = store.openTranscript.compactMap { event -> String? in
if case .userText(let chunk) = event.kind, chunk.text == draft { return chunk.text }
return nil
}
#expect(visibleDrafts == [draft])
}
@Test(.timeLimit(.minutes(1)))
func preparingHandoverStashesSendsOutsideOldControllerAndCancelRestoresThem() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let root = repo.root
let gate = ContextSwitchHandoverGate()
let oldTurns = LockedBox(0)
let oldBackend = ScriptedBackend { emitter, _ in
let turn = oldTurns.get() + 1
oldTurns.set(turn)
emitter.emit(.sessionStarted(SessionStarted(
backendSessionID: "old-native", model: "gpt-5.6-sol", cwd: root,
toolNames: [])))
if turn == 1 {
emitter.emit(.assistantText(TextChunk(
messageID: "old", text: "API complete.", isPartial: false)))
} else {
await gate.wait()
emitter.emit(.assistantText(TextChunk(
messageID: "brief", text: "Handover brief.", isPartial: false)))
}
emitter.emit(.runFinished(RunFinished(outcome: .completed)))
}
let unusedNewBackend = ScriptedBackend { _, _ in }
let store = AppStore(
database: try GRDBMetadataStore(path: nil),
worktrees: GitWorktreeManager(),
transcriptsDir: URL(fileURLWithPath: repo.container)
.appendingPathComponent("transcripts-cancel")
) { session in
session.backend == .codex ? oldBackend : unusedNewBackend
}
store.contextSwitchEnabled = true
store.defaultModel = "claude-opus-5"
store.defaultEffort = "high"
let project = try #require(await store.addProject(
name: "context-switch-cancel", rootPath: root, defaultBranch: "main"))
let sessionID = try await store.createSession(
in: project, title: "backend work", prompt: "Implement the API endpoint",
model: "gpt-5.6-sol", effort: "xhigh",
routing: RoutingNote(
purpose: .backendImpl, level: .deep, reason: "Backend · Deep"),
useWorktree: false)
store.openSessionID = sessionID
await store.awaitOpenSessionSettled()
let initialDraft = "Build the SwiftUI settings screen and toolbar"
await store.contextSwitchDraftChanged(
initialDraft,
verdict: PurposeVerdict(
purpose: .frontendImpl, confidence: .high, source: .heuristic,
reason: "draft UI signal"),
for: sessionID)
let offer = try #require(store.contextSwitchOffer(for: sessionID))
let accepting = Task {
await store.acceptContextSwitch(
offer.id, for: sessionID, pendingDraft: initialDraft)
}
while store.contextSwitchOffer(for: sessionID)?.phase != .preparing {
await Task.yield()
}
let followUp = "Also include keyboard shortcuts"
await store.sendToOpenSession(followUp)
#expect(store.contextSwitchStashedDraft(for: sessionID)
== initialDraft + "\n" + followUp)
#expect(await store.liveSnapshot(sessionID)?.session.queuedMessages.isEmpty == true)
let restored = await store.cancelContextSwitchPreparation(
offer.id, for: sessionID)
await gate.release()
await accepting.value
#expect(restored == initialDraft + "\n" + followUp)
#expect(store.contextSwitchOffer(for: sessionID) == nil)
#expect(unusedNewBackend.lastRun == nil)
#expect(oldBackend.lastResume?.prompt?.plainText?.contains(followUp) == false)
}
}