751 lines
34 KiB
Swift
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)
|
|
}
|
|
}
|