Merge nucleic/warm-coral-viper-sn0m into dev

This commit is contained in:
2026-08-04 04:18:59 -07:00
parent 5607b222c8
commit a3fb1fd4ae
17 changed files with 1716 additions and 43 deletions
+6
View File
@@ -352,6 +352,9 @@ enum ModelCatalog {
/// instead of the manual Model + Effort menus. On by default; the Settings toggle
/// restores manual selection.
static let intelligenceSliderEnabledKey = "nucleic.intelligenceSliderEnabled"
/// Whether Intelligence-routed chats may offer an in-place handover when their work drifts
/// to a purpose whose routing lane uses a different model. Dark-shipped off by default.
static let contextSwitchEnabledKey = "nucleic.contextSwitchEnabled"
/// The Intelligence level new chats start on (raw `IntelligenceLevel` 15).
static let defaultIntelligenceLevelKey = "nucleic.defaultIntelligenceLevel"
/// Restrict Intelligence routing to a single provider (`BackendID` raw value; "" = any
@@ -389,6 +392,9 @@ enum ModelCatalog {
static var storedIntelligenceSliderEnabled: Bool {
UserDefaults.standard.object(forKey: intelligenceSliderEnabledKey) as? Bool ?? true
}
static var storedContextSwitchEnabled: Bool {
UserDefaults.standard.object(forKey: contextSwitchEnabledKey) as? Bool ?? false
}
static var storedDefaultIntelligenceLevel: IntelligenceLevel {
IntelligenceLevel(rawValue: UserDefaults.standard.integer(forKey: defaultIntelligenceLevelKey))
?? .fallback
+1
View File
@@ -200,6 +200,7 @@ struct NucleicApp: App {
// phone's rail routes under the identical policy (SYNC §5.2 see RemoteIntelligence).
store.intelligenceRoutingEnabled = ModelCatalog.storedIntelligenceSliderEnabled
store.intelligencePinnedProvider = ModelCatalog.storedIntelligencePinnedProvider
store.contextSwitchEnabled = ModelCatalog.storedContextSwitchEnabled
store.defaultAuto = ModelCatalog.storedDefaultAuto
store.defaultAutoShip = ModelCatalog.storedDefaultAutoShip
store.summarizeToolCalls = TranscriptDisplay.storedSummarizeToolCalls
+24 -5
View File
@@ -17,6 +17,14 @@ import Observation
@MainActor
@Observable
final class PromptPurposeService {
/// Which classifier ladder a composer is allowed to use. New-chat routing may use the full
/// configured stack; Context Switch is deliberately zero-network and stops after the bundled
/// model, while retaining this type's debounce/cache behavior.
enum ClassificationScope: Sendable {
case layered
case localOnly
}
enum PreviewState: Equatable {
case idle
case pending
@@ -41,6 +49,7 @@ final class PromptPurposeService {
/// The one-shot small-SKU escalation behind AFM nil removes that rung (tests, or a Mac
/// with no agent CLI; the call itself also degrades harmlessly if the CLI is missing).
private let escalation: (any IntelligenceProviding)?
private let classificationScope: ClassificationScope
/// Backends this host can actually reach, kept current by the mounting composer before each
/// classification. The cloud lane only ever runs on a provider the user is already signed in
@@ -73,11 +82,13 @@ final class PromptPurposeService {
init(
intelligence: any IntelligenceProviding,
modelClassifier: (any PurposeModelClassifying)? = PurposeMLClassifier.shared,
escalation: (any IntelligenceProviding)? = DelegatedIntelligenceProvider.agent()
escalation: (any IntelligenceProviding)? = DelegatedIntelligenceProvider.agent(),
classificationScope: ClassificationScope = .layered
) {
self.intelligence = intelligence
self.modelClassifier = modelClassifier
self.escalation = escalation
self.classificationScope = classificationScope
}
/// Registers an immediate text edit, before the composer's debounce. Returns true only when
@@ -156,10 +167,18 @@ final class PromptPurposeService {
inFlightDraft = normalized
previewState = .classifying
let cloud = self.cloud
let task = Task { [intelligence, modelClassifier, escalation] in
await Self.classifyLayered(
normalized, modelClassifier: modelClassifier,
intelligence: intelligence, escalation: escalation, cloud: cloud)
let task = Task { [
intelligence, modelClassifier, escalation, classificationScope
] in
switch classificationScope {
case .layered:
await Self.classifyLayered(
normalized, modelClassifier: modelClassifier,
intelligence: intelligence, escalation: escalation, cloud: cloud)
case .localOnly:
await ContextSwitchPurposeClassifier.classify(
normalized, modelClassifier: modelClassifier)
}
}
inFlight = task
let result = await task.value
+286 -1
View File
@@ -166,6 +166,10 @@ struct SessionDetailView: View {
@State private var sliderOrchestraSelected = false
@State private var sliderOrchestraPreview: Bool?
@State private var lockedRoutedPurpose = PromptPurpose.general
/// Local-only purpose watcher for the live composer. It reuses the new-chat service's
/// debounce/cache machinery but is constrained to heuristic + bundled Core ML: Context Switch
/// never wakes AFM, a CLI, or the network merely because the user is typing.
@State private var contextSwitchPurposeService: PromptPurposeService?
/// Optimistic pair chosen from the labels. It keeps both words instant while the session
/// mutation crosses an actor or the mesh; the persisted session remains authoritative.
@State private var sliderManualModel: String?
@@ -181,6 +185,29 @@ struct SessionDetailView: View {
private var submitMode: SubmitKeyMode { SubmitKeyMode(rawValue: submitKeyRaw) ?? .modifierSends }
/// Include every eligibility edge in the debounce identity. That makes an already-typed draft
/// classify as soon as a turn becomes idle or an approval clears, and retracts a draft offer
/// immediately when provenance, status, or the kill switch moves the other way.
private var contextSwitchDraftClassificationTaskID: String {
let prompt = composerModelOverride?.promptText(in: draft) ?? draft
let approval = store.openApprovals.first?.id.rawValue ?? "none"
let parts = [
session?.id.rawValue ?? "none",
String(PromptPurposeService.normalizedHash(prompt)),
session?.status.rawValue ?? "none",
session?.routedPurpose ?? "none",
session?.routedLevel.map(String.init) ?? "none",
String(session?.contextSwitchMuted ?? false),
String(store.contextSwitchEnabled),
approval,
store.openHostID ?? "local",
store.openTranscriptReadySessionID?.rawValue ?? "loading",
String(composerModelOverride != nil),
String(contextSwitchPurposeService != nil),
]
return parts.joined(separator: ":")
}
/// Width reserved for the send button, so the bottom controls row can be
/// inset to line up with the text field's trailing edge.
private let sendButtonWidth: CGFloat = 28
@@ -380,6 +407,38 @@ struct SessionDetailView: View {
clearSessionRoutedSelection()
preComposerOverrideIntelligenceLevel = nil
preComposerOverrideOrchestraSelected = nil
contextSwitchPurposeService = PromptPurposeService(
intelligence: store.intelligence,
modelClassifier: store.purposeModelClassifier,
escalation: nil,
classificationScope: .localOnly)
}
// Context Switch classifies only after the session/controller eligibility gate passes.
// The id-keyed task is cancelled and restarted across edits, chat changes, status changes,
// approvals, routing provenance changes, and the rollout toggle. The service separately
// filters cosmetic edits, so punctuation fixes refresh the captured draft without paying
// for another classifier pass.
.task(id: contextSwitchDraftClassificationTaskID) {
guard let sessionID = session?.id,
let service = contextSwitchPurposeService
else { return }
let prompt = composerModelOverride?.promptText(in: draft) ?? draft
guard composerModelOverride == nil else {
await store.contextSwitchDraftChanged("", verdict: nil, for: sessionID)
return
}
guard await store.contextSwitchDraftIsEligible(sessionID) else { return }
guard service.prepareDraftChange(prompt) else {
await store.contextSwitchDraftChanged(
prompt, verdict: service.verdict, for: sessionID)
return
}
try? await Task.sleep(for: .milliseconds(700))
guard !Task.isCancelled else { return }
let verdict = await service.draftChanged(prompt)
guard !Task.isCancelled else { return }
await store.contextSwitchDraftChanged(prompt, verdict: verdict, for: sessionID)
}
.onChange(of: composerModelOverride?.selection) { old, selection in
sliderIntelligenceLevelPreview = nil
@@ -2835,10 +2894,23 @@ struct SessionDetailView: View {
return store.openStalls.first
}
/// Context Switch is the lowest-priority composer reveal. A permission, lock wait, or hung
/// command is an active blocker and temporarily hides the offer; the store retains it and
/// projects it again as soon as the blocker retires.
private var visibleContextSwitchOffer: ContextSwitchOffer? {
guard !isArchived, store.openHostID == nil,
store.openApprovals.first == nil, openLockWait == nil, openStall == nil,
let sessionID = session?.id,
let offer = store.openContextSwitchOffer, offer.sessionID == sessionID
else { return nil }
return offer
}
/// Whether the composer's reveal slot has anything to show a permission/question request,
/// a hang alert, a lock-contention card, or any combination.
/// a hang alert, a lock-contention card, a context-switch offer, or any combination.
private var hasComposerRevealContent: Bool {
store.openApprovals.first != nil || openStall != nil || openLockWait != nil
|| visibleContextSwitchOffer != nil
}
/// A real trailing element and fixed scroll target. Kept as its own final stack child so the
@@ -3046,6 +3118,26 @@ struct SessionDetailView: View {
// Both cards share one reveal slot, stacked, so a lock wait that coincides with a
// permission request grows the same glass instead of opening a second surface.
VStack(spacing: 8) {
if let offer = visibleContextSwitchOffer {
ContextSwitchBar(
offer: offer,
currentModel: effectiveModel,
currentEffort: effectiveEffort,
stashedDraft: store.contextSwitchStashedDraft(for: offer.sessionID),
onSwitch: { acceptContextSwitch(offer) },
onDecline: {
Task {
await store.declineContextSwitch(
offer.id, for: offer.sessionID)
}
},
onCancel: { cancelContextSwitch(offer) },
onUseDigest: { useDigestForContextSwitch(offer) },
onDismiss: { cancelContextSwitch(offer) },
onMute: { muteContextSwitch(offer) })
.id(offer.id)
.transition(ComposerMotion.inputTransition(reduceMotion))
}
// A chat parked in the file-lock queue (or caught in a lock deadlock) must
// explain itself where the user is about to type into what looks like a
// frozen chat beside the send button, not buried at the transcript's tail.
@@ -3128,6 +3220,14 @@ struct SessionDetailView: View {
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: openStall?.id)
// Context Switch is another app-originated reveal card; its identity changes when the
// detected situation changes, while its phase morphs in place during preparation.
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: store.openContextSwitchOffer?.id)
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: store.openContextSwitchOffer?.phase)
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: composerHeight)
@@ -3759,6 +3859,42 @@ struct SessionDetailView: View {
}
}
/// Accept clears the text field immediately, but the store captures the same text before any
/// accept-time route check and owns it until the new engine starts. Attachments remain staged:
/// the v1 handover contract is text-only, so silently consuming media would be surprising.
private func acceptContextSwitch(_ offer: ContextSwitchOffer) {
let captured = draft
draft = ""
Task {
await store.acceptContextSwitch(
offer.id, for: offer.sessionID, pendingDraft: captured)
}
}
private func cancelContextSwitch(_ offer: ContextSwitchOffer) {
Task {
let restored = await store.cancelContextSwitchPreparation(
offer.id, for: offer.sessionID)
if let restored { appendPulledPrompt(restored, to: &draft) }
}
}
private func useDigestForContextSwitch(_ offer: ContextSwitchOffer) {
let captured = draft
draft = ""
Task {
await store.useDigestForContextSwitch(
offer.id, for: offer.sessionID, pendingDraft: captured)
}
}
private func muteContextSwitch(_ offer: ContextSwitchOffer) {
Task {
let restored = await store.muteContextSwitch(offer.id, for: offer.sessionID)
if let restored { appendPulledPrompt(restored, to: &draft) }
}
}
/// Flip the open chat out of the archive, restoring an interactive composer and
/// transcript. Wired to the Unarchive button that replaces send while archived.
/// Routed through the open-session verb so it reaches a peer Mac's chat too the
@@ -4304,6 +4440,155 @@ private struct JumpToBottomButton: View {
}
}
/// Composer-side offer to move an Intelligence-routed chat onto the lane that now fits its work.
/// The card owns presentation only: detection, cooldowns, handover preparation, and the in-place
/// engine swap remain AppStore/Core concerns. Its identity stays stable as it morphs through
/// offered preparing failed, preventing a stack of transient banners in the reveal slot.
struct ContextSwitchBar: View {
@Environment(\.appPalette) private var palette
let offer: ContextSwitchOffer
let currentModel: String
let currentEffort: String
let stashedDraft: String?
let onSwitch: () -> Void
let onDecline: () -> Void
let onCancel: () -> Void
let onUseDigest: () -> Void
let onDismiss: () -> Void
let onMute: () -> Void
private var targetName: String { ModelCatalog.displayName(offer.resolution.model) }
private var targetEffort: String {
ModelCatalog.effortDisplayName(offer.resolution.effort)
}
private var sourceDescription: String {
switch offer.trigger {
case .draft: return "The draft"
case .turnEnd: return "The last turn"
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Context switch?", systemImage: "arrow.triangle.2.circlepath")
.font(.callout.weight(.semibold))
.foregroundStyle(palette.accent)
switch offer.phase {
case .offered:
offeredContent
case .preparing:
preparingContent
case .failed(let message):
failedContent(message)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.accent.opacity(0.10), in: .rect(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(palette.accent.opacity(0.38), lineWidth: 1))
}
private var offeredContent: some View {
VStack(alignment: .leading, spacing: 8) {
Text(
"This chat was routed for \(offer.fromPurpose.displayName) "
+ "(\(ModelCatalog.displayName(currentModel)) · "
+ "\(ModelCatalog.effortDisplayName(currentEffort))). "
+ "\(sourceDescription) reads like \(offer.toPurpose.displayName).")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text("\(offer.level.displayName) level → \(targetName) · \(targetEffort) effort")
.font(.caption.weight(.medium))
HStack(spacing: 8) {
Menu {
Button("Don't offer again for this chat", action: onMute)
} label: {
Image(systemName: "ellipsis")
.frame(width: 20, height: 16)
}
.menuStyle(.borderlessButton)
.fixedSize()
.accessibilityLabel("More context switch options")
Spacer()
Button("Not now", action: onDecline)
.buttonStyle(.bordered)
.controlSize(.small)
Button("Switch to \(targetName)", action: onSwitch)
.buttonStyle(.borderedProminent)
.controlSize(.small)
.tint(palette.accent)
.keyboardShortcut(.defaultAction)
}
}
}
private var preparingContent: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
ProgressView().controlSize(.small)
Text(
"Preparing handover — the current agent is writing a brief for "
+ "\(targetName)")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let queued = stashedDraft?.trimmingCharacters(
in: .whitespacesAndNewlines), !queued.isEmpty
{
Label {
Text(queued).lineLimit(2)
} icon: {
Text("Queued").font(.caption2.weight(.semibold))
}
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(AppTheme.composerFill, in: .rect(cornerRadius: 8))
}
HStack {
Spacer()
Button("Cancel", action: onCancel)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
private func failedContent(_ message: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(message)
.font(.caption)
.foregroundStyle(palette.attention)
.fixedSize(horizontal: false, vertical: true)
if let queued = stashedDraft?.trimmingCharacters(
in: .whitespacesAndNewlines), !queued.isEmpty
{
Text("Your pending draft is saved and will be restored if you dismiss.")
.font(.caption2)
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
Spacer()
Button("Dismiss", action: onDismiss)
.buttonStyle(.bordered)
.controlSize(.small)
Button("Use digest instead", action: onUseDigest)
.buttonStyle(.borderedProminent)
.controlSize(.small)
.tint(palette.accent)
}
}
}
}
/// Composer card for a chat parked in the file-lock queue (LOCKING §4): names the contended files
/// and the chat(s) holding them, escalates to the attention style for a genuine deadlock, and
/// offers the manual escape hatch releasing the holders' locks. It rides in the composer's glass
+46
View File
@@ -2664,6 +2664,7 @@ private struct AgentsSettingsTab: View {
@AppStorage(ModelCatalog.defaultOrchestraMaxWorkersKey) private var defaultOrchestraMaxWorkers = ModelCatalog.fallbackOrchestraMaxWorkers
@AppStorage(ModelCatalog.defaultAutoKey) private var defaultAuto = false
@AppStorage(ModelCatalog.intelligenceSliderEnabledKey) private var intelligenceSliderEnabled = true
@AppStorage(ModelCatalog.contextSwitchEnabledKey) private var contextSwitchEnabled = false
@AppStorage(ModelCatalog.defaultIntelligenceLevelKey) private var defaultIntelligenceLevel =
IntelligenceLevel.fallback.rawValue
@AppStorage(ModelCatalog.intelligencePinnedProviderKey) private var intelligencePinnedProvider = ""
@@ -2738,6 +2739,45 @@ private struct AgentsSettingsTab: View {
+ "overpowered model for a simple task. Turn off to pick model and "
+ "effort manually.")
.settingsCaption()
Toggle("Offer context switches", isOn: $contextSwitchEnabled)
Text(
"When a chat's work drifts to a different kind of task, offer to hand the "
+ "chat over to the better-fit model.")
.settingsCaption()
if !contextSwitchEnabled {
DisclosureGroup(
"Canary log (\(store.contextSwitchCanaryEvents.count) would-have-offered)")
{
if store.contextSwitchCanaryEvents.isEmpty {
Text(
"No would-have-offered events yet. Detection stays local and "
+ "records no prompt or reply text.")
.settingsCaption()
} else {
ForEach(store.contextSwitchCanaryEvents.suffix(5).reversed()) { event in
VStack(alignment: .leading, spacing: 2) {
Text(
"\(contextSwitchPurposeName(event.fromPurpose))"
+ "\(contextSwitchPurposeName(event.toPurpose)) · "
+ "\(ModelCatalog.displayName(event.model)) · "
+ ModelCatalog.effortDisplayName(event.effort))
.font(.caption.weight(.medium))
Text(
"\(event.trigger == .draft ? "Draft" : "Turn end") · "
+ "\(event.confidence.uppercased()) via \(event.source): "
+ event.reason)
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
Text(ContextSwitchCanaryLog.logFileURL.path)
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
.textSelection(.enabled)
}
}
if intelligenceSliderEnabled {
Picker("Default level", selection: $defaultIntelligenceLevel) {
ForEach(IntelligenceLevel.allCases) { level in
@@ -2880,6 +2920,7 @@ private struct AgentsSettingsTab: View {
// from it), so these two have to push as well as persist turning the slider off here
// must retire the phone's rail too, not just this Mac's.
.onChange(of: intelligenceSliderEnabled) { _, _ in pushDefaults() }
.onChange(of: contextSwitchEnabled) { _, _ in pushDefaults() }
.onChange(of: defaultIntelligenceLevel) { _, _ in pushDefaults() }
.onChange(of: intelligencePinnedProvider) { _, _ in pushDefaults() }
}
@@ -2962,6 +3003,7 @@ private struct AgentsSettingsTab: View {
store.defaultIntelligenceLevel =
IntelligenceLevel(rawValue: defaultIntelligenceLevel) ?? .fallback
store.intelligenceRoutingEnabled = intelligenceSliderEnabled
store.contextSwitchEnabled = contextSwitchEnabled
store.intelligencePinnedProvider =
intelligencePinnedProvider.isEmpty
? nil : BackendID(rawValue: intelligencePinnedProvider)
@@ -2971,6 +3013,10 @@ private struct AgentsSettingsTab: View {
store.refreshRemoteIntelligenceCatalogAfterSettingsChange()
}
private func contextSwitchPurposeName(_ rawValue: String) -> String {
PromptPurpose(rawValue: rawValue)?.displayName ?? rawValue
}
/// Toggle binding for one provider's status monitoring: reads/writes the live store set
/// (so the row reflects state immediately) and persists the choice for next launch.
private func providerBinding(_ provider: StatusProvider) -> Binding<Bool> {
+464 -19
View File
@@ -811,6 +811,17 @@ public final class AppStore: ConflictArbiter {
/// Offers live per session so switching chats does not discard background detection.
private var contextSwitchOffers: [SessionID: ContextSwitchOffer] = [:]
private var contextSwitchThrottle = ContextSwitchThrottle()
/// Last logged canary identity per session, preventing draft debounce edits from recording the
/// same visible situation over and over while the feature is dark-shipped.
private var contextSwitchCanaryKeys: [SessionID: String] = [:]
public private(set) var contextSwitchCanaryEvents =
ContextSwitchCanaryLog.recentEvents()
/// Accept-flow ownership and composer sends captured while the outgoing agent writes its brief.
/// These stay outside SessionController's ordinary pending queue so no queued user turn can
/// race `finishRun` and start on the old engine.
private var contextSwitchPreparationTasks: [SessionID: Task<Void, Never>] = [:]
private var contextSwitchPreparationTokens: [SessionID: UUID] = [:]
private var contextSwitchStashedDrafts: [SessionID: String] = [:]
private var summaryTask: Task<Void, Never>?
/// Most recently issued tool-group summary task (one of possibly several per
/// transcript update); drained by the settle helper alongside the others.
@@ -8282,6 +8293,12 @@ public final class AppStore: ConflictArbiter {
return
}
guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
if contextSwitchPreparationTokens[sessionID] != nil {
let (augmented, _) = await augmentWithAttachments(
text, attachments, worktree: openSession?.worktreePath)
stashContextSwitchDraft(augmented, for: sessionID)
return
}
// Gate on the launch lock/nvrsion reconciliation (plan item 6): a locally-run turn must
// never start against not-yet-rebuilt lock state. Free once the gate completes; the
// remote-peer branch above is exempt the owning Mac's lock state governs that turn.
@@ -8294,6 +8311,11 @@ public final class AppStore: ConflictArbiter {
let (augmented, queuedAttachments) = await augmentWithAttachments(
text, attachments, worktree: openSession?.worktreePath)
let display = QueuedMessage(text: text, attachments: queuedAttachments)
if let offer = contextSwitchOffers[sessionID], offer.phase == .offered {
contextSwitchThrottle.implicitlyDecline(
offer, atUserTurn: (await controller.snapshot).userTurnCount)
setContextSwitchOffer(nil, for: sessionID)
}
do {
try await controller.sendInput(AgentInput(text: augmented), display: display)
maybeNameSession(sessionID, firstMessage: augmented) // first send names the session
@@ -8797,7 +8819,11 @@ public final class AppStore: ConflictArbiter {
projectors[id] = nil
controllers[id] = nil
contextSwitchTasks.removeValue(forKey: id)?.cancel()
contextSwitchPreparationTasks.removeValue(forKey: id)?.cancel()
contextSwitchPreparationTokens[id] = nil
contextSwitchStashedDrafts[id] = nil
contextSwitchOffers[id] = nil
contextSwitchCanaryKeys[id] = nil
contextSwitchThrottle.removeSession(id)
if openSessionID == id { openContextSwitchOffer = nil }
persistedSessionRecords[id] = nil
@@ -9489,14 +9515,20 @@ public final class AppStore: ConflictArbiter {
// no-op when the session was never pinned.
await containerManager?.releaseBackgroundWait(sessionID)
await macVMManager?.releaseBackgroundWait(sessionID)
classifyDisposition(sessionID, controller, outcome: finished.outcome)
if contextSwitchPreparationTokens[sessionID] == nil {
classifyDisposition(sessionID, controller, outcome: finished.outcome)
}
}
// Mesh dispatch: the active-session count changed refresh the presence card.
publishRunnerPresence()
// Covalence: a turn just ended the between-turn window where a managed session may
// move to a strictly better host. Debounced; no-ops when nothing here is managed.
scheduleCovalenceRebalanceSweep()
if !finished.waitingOnBackground {
if contextSwitchPreparationTokens[sessionID] == nil {
scheduleCovalenceRebalanceSweep()
}
if !finished.waitingOnBackground,
contextSwitchPreparationTokens[sessionID] == nil
{
scheduleContextSwitchTurnEnd(sessionID, controller, seq: event.seq)
}
}
@@ -10868,6 +10900,386 @@ public final class AppStore: ConflictArbiter {
contextSwitchOffers[sessionID]
}
/// User text held outside the controller while a handover turn is running. The offer card uses
/// this as its queued pill; cancel returns the same text to the composer.
public func contextSwitchStashedDraft(for sessionID: SessionID) -> String? {
contextSwitchStashedDrafts[sessionID]
}
/// Accept a visible offer and run the complete old-agent brief engine swap fresh first-turn
/// sequence. This method owns a cancellable task so the card's Cancel action can interrupt the
/// handover while the caller is suspended awaiting completion.
public func acceptContextSwitch(
_ offerID: UUID, for sessionID: SessionID, pendingDraft: String?
) async {
guard openHostID == nil,
let controller = controllers[sessionID],
var offer = contextSwitchOffers[sessionID], offer.id == offerID,
offer.phase == .offered
else { return }
// Capture before any accept-time check can fail. The composer clears optimistically when
// Switch is pressed; a stale route or newly-arrived blocker must leave that text attached
// to the failed card so dismiss can restore it instead of losing the user's draft.
let captured = nonempty(pendingDraft)
?? {
guard case .draft(let text) = offer.trigger else { return nil }
return nonempty(text)
}()
contextSwitchStashedDrafts[sessionID] = captured
let snapshot = await controller.snapshot
guard ContextSwitchEvaluator.preflight(
session: snapshot.session,
enabled: contextSwitchEnabled,
controllerIsIdle: snapshot.isBetweenTurns,
hasPendingApproval: !snapshot.pendingApprovals.isEmpty) == nil
else {
failContextSwitchOffer(
sessionID, message: "This chat is no longer idle enough to switch engines.")
return
}
let target: ContextSwitchTarget
switch ContextSwitchTargetResolver.resolve(
session: snapshot.session,
detectedPurpose: offer.toPurpose,
routing: contextSwitchRoutingContext)
{
case .success(let resolved):
target = resolved
offer.resolution = resolved.resolution
case .failure(let failure):
failContextSwitchOffer(sessionID, message: contextSwitchFailureMessage(failure))
return
}
offer.phase = .preparing
setContextSwitchOffer(offer, for: sessionID)
let token = UUID()
contextSwitchPreparationTokens[sessionID] = token
let task = Task { [weak self] in
guard let self else { return }
await self.performContextSwitch(
offer: offer, target: target, sourceSession: snapshot.session,
controller: controller, token: token,
useDigestOnly: false, engineAlreadySwitched: false)
}
contextSwitchPreparationTasks[sessionID] = task
await task.value
}
/// Retry a failed preparation with the deterministic local digest. If the engine swap already
/// succeeded and only its first turn failed to start, this resumes directly on that fresh
/// engine; otherwise it re-resolves and performs the swap without spending another old-agent
/// handover turn.
public func useDigestForContextSwitch(
_ offerID: UUID, for sessionID: SessionID, pendingDraft: String? = nil
) async {
guard openHostID == nil,
let controller = controllers[sessionID],
var offer = contextSwitchOffers[sessionID], offer.id == offerID,
case .failed = offer.phase
else { return }
if let pendingDraft = nonempty(pendingDraft) {
stashContextSwitchDraft(pendingDraft, for: sessionID)
}
let snapshot = await controller.snapshot
guard snapshot.isBetweenTurns, snapshot.pendingApprovals.isEmpty,
!snapshot.session.status.isTerminal
else {
failContextSwitchOffer(
sessionID, message: "This chat is no longer idle enough to switch engines.")
return
}
let alreadySwitched = snapshot.session.routedPurpose == offer.toPurpose.rawValue
&& snapshot.session.model == offer.resolution.model
&& snapshot.session.backendSessionID == nil
let target: ContextSwitchTarget
if alreadySwitched, let backend = offer.targetBackend {
target = ContextSwitchTarget(
fromPurpose: offer.fromPurpose, level: offer.level,
backend: backend, resolution: offer.resolution)
} else {
switch ContextSwitchTargetResolver.resolve(
session: snapshot.session,
detectedPurpose: offer.toPurpose,
routing: contextSwitchRoutingContext)
{
case .success(let resolved):
target = resolved
offer.resolution = resolved.resolution
case .failure(let failure):
failContextSwitchOffer(sessionID, message: contextSwitchFailureMessage(failure))
return
}
}
offer.phase = .preparing
setContextSwitchOffer(offer, for: sessionID)
let token = UUID()
contextSwitchPreparationTokens[sessionID] = token
let task = Task { [weak self] in
guard let self else { return }
await self.performContextSwitch(
offer: offer, target: target, sourceSession: snapshot.session,
controller: controller, token: token,
useDigestOnly: true, engineAlreadySwitched: alreadySwitched)
}
contextSwitchPreparationTasks[sessionID] = task
await task.value
}
/// Cancel a handover or dismiss a failed one, restoring every captured send to the composer.
/// Cancellation uses the ordinary full decline cooldown because it is an explicit "not now".
public func cancelContextSwitchPreparation(
_ offerID: UUID, for sessionID: SessionID
) async -> String? {
guard let offer = contextSwitchOffers[sessionID], offer.id == offerID else { return nil }
contextSwitchPreparationTasks.removeValue(forKey: sessionID)?.cancel()
contextSwitchPreparationTokens[sessionID] = nil
if let controller = controllers[sessionID] {
await controller.interrupt()
contextSwitchThrottle.decline(
offer, atUserTurn: (await controller.snapshot).userTurnCount, now: now())
}
let restored = contextSwitchStashedDrafts.removeValue(forKey: sessionID)
setContextSwitchOffer(nil, for: sessionID)
return restored
}
public func declineContextSwitch(_ offerID: UUID, for sessionID: SessionID) async {
guard let offer = contextSwitchOffers[sessionID], offer.id == offerID else { return }
if offer.phase != .offered {
_ = await cancelContextSwitchPreparation(offerID, for: sessionID)
return
}
if let controller = controllers[sessionID] {
contextSwitchThrottle.decline(
offer, atUserTurn: (await controller.snapshot).userTurnCount, now: now())
}
setContextSwitchOffer(nil, for: sessionID)
}
public func muteContextSwitch(_ offerID: UUID, for sessionID: SessionID) async -> String? {
guard contextSwitchOffers[sessionID]?.id == offerID else { return nil }
let restored: String?
if contextSwitchOffers[sessionID]?.phase != .offered {
restored = await cancelContextSwitchPreparation(offerID, for: sessionID)
} else {
restored = nil
setContextSwitchOffer(nil, for: sessionID)
}
await controllers[sessionID]?.setContextSwitchMuted(true)
contextSwitchThrottle.removeSession(sessionID)
return restored
}
private func performContextSwitch(
offer: ContextSwitchOffer,
target: ContextSwitchTarget,
sourceSession: Session,
controller: SessionController,
token: UUID,
useDigestOnly: Bool,
engineAlreadySwitched: Bool
) async {
let sessionID = offer.sessionID
let todoItems = todos.filter { $0.dispatchedSessionID == sessionID }.map(\.text)
let events = await controller.transcriptSoFar()
let digest = DigestHandover(todoItems: todoItems)
let brief = if useDigestOnly {
await digest.compose(
session: sourceSession, controller: controller, events: events, target: target)
} else {
await OutgoingAgentHandover(fallback: digest).compose(
session: sourceSession, controller: controller, events: events, target: target)
}
guard !Task.isCancelled, contextSwitchPreparationTokens[sessionID] == token else { return }
do {
if !engineAlreadySwitched {
let latest = await controller.snapshot
var factorySession = latest.session
factorySession.setBackendFromController(target.backend)
factorySession.model = target.resolution.model
factorySession.effort = target.resolution.effort
let newBackend = backendFactory(factorySession)
let oldModel = latest.session.model ?? latest.session.backend.rawValue
let oldEffort = latest.session.effort ?? EffortLadder.fallbackEffort
let routingNote = RoutingNote(
purpose: offer.toPurpose,
level: offer.level,
reason: "context-switched from \(offer.fromPurpose.rawValue): "
+ target.resolution.reason)
let divider = "⇄ Context switched — \(offer.fromPurpose.displayName)"
+ "\(offer.toPurpose.displayName) · \(oldModel) (\(oldEffort)) → "
+ "\(target.resolution.model) (\(target.resolution.effort)) · handover brief above"
do {
try await controller.switchEngine(
to: target.resolution.model,
effort: target.resolution.effort,
backendID: target.backend,
backend: newBackend,
routingNote: routingNote,
dividerText: divider)
} catch {
await newBackend.shutdown()
throw error
}
}
let draft = contextSwitchStashedDrafts[sessionID]
try await controller.beginSwitchedTurn(handover: brief.markdown, userText: draft)
} catch {
guard contextSwitchPreparationTokens[sessionID] == token else { return }
contextSwitchPreparationTokens[sessionID] = nil
contextSwitchPreparationTasks[sessionID] = nil
failContextSwitchOffer(
sessionID, message: "The engine switch could not be completed: \(error)")
return
}
contextSwitchThrottle.accept(offer)
contextSwitchPreparationTokens[sessionID] = nil
contextSwitchPreparationTasks[sessionID] = nil
contextSwitchStashedDrafts[sessionID] = nil
setContextSwitchOffer(nil, for: sessionID)
let switched = await controller.snapshot
upsertSummary(SessionSummary(
switched.session, pendingApprovals: switched.pendingApprovals))
if openSessionID == sessionID { openSession = switched.session }
broadcast(.sessionUpdated(wireSummary(
for: switched.session, pendingApprovals: switched.pendingApprovals)))
}
private func stashContextSwitchDraft(_ text: String, for sessionID: SessionID) {
guard let text = nonempty(text) else { return }
if let existing = contextSwitchStashedDrafts[sessionID], !existing.isEmpty {
contextSwitchStashedDrafts[sessionID] = existing + "\n" + text
} else {
contextSwitchStashedDrafts[sessionID] = text
}
// The offer is a value inside an unobserved registry; re-projecting it wakes the selected
// card so its queued pill updates even though the phase/id did not change.
if let offer = contextSwitchOffers[sessionID] {
setContextSwitchOffer(offer, for: sessionID)
}
}
private func failContextSwitchOffer(_ sessionID: SessionID, message: String) {
guard var offer = contextSwitchOffers[sessionID] else { return }
offer.phase = .failed(message: message)
setContextSwitchOffer(offer, for: sessionID)
}
private func contextSwitchFailureMessage(_ failure: ContextSwitchTargetFailure) -> String {
switch failure {
case .missingRoutingNote: return "This chat is no longer Intelligence-routed."
case .samePurpose: return "The draft now matches this chat's current route."
case .unavailable(let reason): return reason
case .sameModel: return "The current model is already the best available target."
case .unknownBackend(let model): return "No agent backend is available for \(model)."
}
}
private func nonempty(_ text: String?) -> String? {
guard let text, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
return text
}
/// Cheap eligibility check for the in-session composer's debounced watcher. Keeping it here
/// means the view never has to approximate controller-idle or pending-approval state from its
/// display projection. The feature-enabled bit is deliberately excluded here: during the
/// default-off canary the same local-only classifier work records would-have-offered events,
/// while manual/orchestra/muted/busy chats still schedule nothing.
public func contextSwitchDraftIsEligible(_ sessionID: SessionID) async -> Bool {
guard openSessionID == sessionID, openHostID == nil,
let controller = controllers[sessionID]
else { return false }
let snapshot = await controller.snapshot
let eligible = ContextSwitchEvaluator.preflight(
session: snapshot.session,
enabled: true,
controllerIsIdle: snapshot.isBetweenTurns,
hasPendingApproval: !snapshot.pendingApprovals.isEmpty) == nil
if !eligible { retractContextSwitchDraftOffer(for: sessionID) }
if !contextSwitchEnabled,
contextSwitchOffers[sessionID]?.phase == .offered
{
setContextSwitchOffer(nil, for: sessionID)
}
return eligible
}
/// Publish the newest resolved draft signal. The session id is explicit because SwiftUI can
/// cancel a debounce at the same moment the user opens another chat; a late answer must never
/// attach the old composer's text to the newly selected session.
public func contextSwitchDraftChanged(
_ text: String, verdict: PurposeVerdict?, for sessionID: SessionID
) async {
guard openSessionID == sessionID, openHostID == nil,
let controller = controllers[sessionID]
else { return }
guard let verdict,
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
retractContextSwitchDraftOffer(for: sessionID)
return
}
let snapshot = await controller.snapshot
switch ContextSwitchEvaluator.evaluate(
session: snapshot.session,
verdict: verdict,
trigger: .draft(text),
routing: contextSwitchRoutingContext,
throttle: contextSwitchThrottle,
currentUserTurn: snapshot.userTurnCount,
now: now(),
enabled: true,
controllerIsIdle: snapshot.isBetweenTurns,
hasPendingApproval: !snapshot.pendingApprovals.isEmpty)
{
case .offer(let offer):
if contextSwitchEnabled {
contextSwitchCanaryKeys[sessionID] = nil
publishContextSwitchOffer(offer, for: sessionID)
} else {
setContextSwitchOffer(nil, for: sessionID)
recordContextSwitchCanary(offer)
}
case .ineligible:
// A resolved draft is the newest natural-language signal. LOW confidence, a return to
// the routed purpose, or a now-unavailable target invalidates any older visible offer.
if contextSwitchOffers[sessionID]?.phase == .offered {
setContextSwitchOffer(nil, for: sessionID)
}
contextSwitchCanaryKeys[sessionID] = nil
}
}
private func recordContextSwitchCanary(_ offer: ContextSwitchOffer) {
let key = switch offer.trigger {
case .draft:
"draft:\(offer.toPurpose.rawValue):\(offer.resolution.model)"
case .turnEnd(let seq):
"turn:\(seq):\(offer.toPurpose.rawValue):\(offer.resolution.model)"
}
guard contextSwitchCanaryKeys[offer.sessionID] != key else { return }
contextSwitchCanaryKeys[offer.sessionID] = key
let event = ContextSwitchCanaryEvent(offer: offer, at: now())
contextSwitchCanaryEvents.append(event)
if contextSwitchCanaryEvents.count > 50 {
contextSwitchCanaryEvents.removeFirst(contextSwitchCanaryEvents.count - 50)
}
ContextSwitchCanaryLog.append(event)
}
private func setContextSwitchOffer(
_ offer: ContextSwitchOffer?, for sessionID: SessionID
) {
@@ -10877,6 +11289,45 @@ public final class AppStore: ConflictArbiter {
}
}
/// Refresh a same-identity offer in place so ongoing edits update the captured draft without
/// re-animating the card. A genuinely different purpose/model gets a fresh identity and id.
private func publishContextSwitchOffer(
_ offer: ContextSwitchOffer, for sessionID: SessionID
) {
if let existing = contextSwitchOffers[sessionID], existing.identity == offer.identity {
setContextSwitchOffer(ContextSwitchOffer(
id: existing.id,
sessionID: offer.sessionID,
fromPurpose: offer.fromPurpose,
toPurpose: offer.toPurpose,
level: offer.level,
resolution: offer.resolution,
trigger: offer.trigger,
verdict: offer.verdict,
phase: existing.phase), for: sessionID)
} else {
setContextSwitchOffer(offer, for: sessionID)
}
}
private func retractContextSwitchDraftOffer(for sessionID: SessionID) {
guard let offer = contextSwitchOffers[sessionID], offer.phase == .offered,
case .draft = offer.trigger
else { return }
setContextSwitchOffer(nil, for: sessionID)
}
private var contextSwitchRoutingContext: ContextSwitchRoutingContext {
ContextSwitchRoutingContext(
connected: connectedProviders,
pinned: intelligencePinnedProvider,
degraded: degradedRoutingProviders,
limits: intelligenceRoutingLimits,
codexPro: isCodexPro,
fallbackModel: defaultModel ?? OrchestrationMode.intelligentWorkerFallbackModel,
fallbackEffort: defaultEffort ?? EffortLadder.fallbackEffort)
}
/// Classify one settled turn on Context Switch's local-only L0/L1 lane. Joining the source
/// run first closes the terminal-event/stream-unwind gap; the second snapshot plus turn-count
/// check discards a stale answer if a queued or newly typed follow-up started meanwhile.
@@ -10893,7 +11344,7 @@ public final class AppStore: ConflictArbiter {
let initial = await controller.snapshot
guard ContextSwitchEvaluator.preflight(
session: initial.session,
enabled: self.contextSwitchEnabled,
enabled: true,
controllerIsIdle: initial.isBetweenTurns,
hasPendingApproval: !initial.pendingApprovals.isEmpty) == nil,
let rawPurpose = initial.session.routedPurpose,
@@ -10932,35 +11383,29 @@ public final class AppStore: ConflictArbiter {
let current = await controller.snapshot
guard current.userTurnCount == initial.userTurnCount else { return }
let fallbackModel = self.defaultModel
?? OrchestrationMode.intelligentWorkerFallbackModel
let fallbackEffort = self.defaultEffort ?? EffortLadder.fallbackEffort
let routing = ContextSwitchRoutingContext(
connected: self.connectedProviders,
pinned: self.intelligencePinnedProvider,
degraded: self.degradedRoutingProviders,
limits: self.intelligenceRoutingLimits,
codexPro: self.isCodexPro,
fallbackModel: fallbackModel,
fallbackEffort: fallbackEffort)
switch ContextSwitchEvaluator.evaluate(
session: current.session,
verdict: selected,
trigger: .turnEnd(seq: seq),
routing: routing,
routing: self.contextSwitchRoutingContext,
throttle: self.contextSwitchThrottle,
currentUserTurn: current.userTurnCount,
now: self.now(),
enabled: self.contextSwitchEnabled,
enabled: true,
controllerIsIdle: current.isBetweenTurns,
hasPendingApproval: !current.pendingApprovals.isEmpty)
{
case .offer(let offer):
if self.contextSwitchOffers[sessionID]?.identity != offer.identity {
self.setContextSwitchOffer(offer, for: sessionID)
if self.contextSwitchEnabled {
self.contextSwitchCanaryKeys[sessionID] = nil
self.publishContextSwitchOffer(offer, for: sessionID)
} else {
self.setContextSwitchOffer(nil, for: sessionID)
self.recordContextSwitchCanary(offer)
}
case .ineligible:
self.setContextSwitchOffer(nil, for: sessionID)
self.contextSwitchCanaryKeys[sessionID] = nil
}
}
}
@@ -0,0 +1,97 @@
import Foundation
import NucleicProtocol
/// One privacy-bounded record that Context Switch would have surfaced while its global toggle was
/// off. Prompt/reply text is intentionally absent; the canary needs only the resolved situation and
/// classifier provenance to judge false-offer rates.
public struct ContextSwitchCanaryEvent: Identifiable, Sendable, Codable, Equatable {
public enum Trigger: String, Sendable, Codable { case draft, turnEnd }
public let id: UUID
public let at: Date
public let sessionID: String
public let trigger: Trigger
public let fromPurpose: String
public let toPurpose: String
public let level: Int
public let model: String
public let effort: String
public let confidence: String
public let source: String
public let reason: String
public init(offer: ContextSwitchOffer, at: Date = Date()) {
id = UUID()
self.at = at
sessionID = offer.sessionID.rawValue
trigger = switch offer.trigger {
case .draft: .draft
case .turnEnd: .turnEnd
}
fromPurpose = offer.fromPurpose.rawValue
toPurpose = offer.toPurpose.rawValue
level = offer.level.rawValue
model = offer.resolution.model
effort = offer.resolution.effort
confidence = offer.verdict.confidence.rawValue
source = offer.verdict.source.rawValue
reason = offer.verdict.reason
}
}
/// Rolling JSON-lines canary log under Nucleic's local logs directory. Writes are serialized away
/// from the main actor and failures are non-fatal; detection must never affect chat execution.
public enum ContextSwitchCanaryLog {
public static var logFileURL: URL {
NucleicPaths.logsDirectory.appendingPathComponent("ContextSwitchCanary.jsonl")
}
private static let queue = DispatchQueue(
label: "com.nucleic.context-switch-canary", qos: .utility)
private static let rotateBytes = 1_000_000
public static func append(_ event: ContextSwitchCanaryEvent) {
queue.async { write(event) }
}
/// Load the newest well-formed records for the Settings review surface. A partial final write
/// or older malformed line is skipped independently rather than hiding the rest of the log.
public static func recentEvents(limit: Int = 50) -> [ContextSwitchCanaryEvent] {
queue.sync {
guard limit > 0, let data = try? Data(contentsOf: logFileURL), !data.isEmpty else {
return []
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return String(decoding: data, as: UTF8.self)
.split(separator: "\n")
.suffix(limit)
.compactMap { try? decoder.decode(
ContextSwitchCanaryEvent.self, from: Data($0.utf8)) }
}
}
private static func write(_ event: ContextSwitchCanaryEvent) {
let fm = FileManager.default
let directory = NucleicPaths.logsDirectory
let url = logFileURL
try? fm.createDirectory(at: directory, withIntermediateDirectories: true)
if let size = (try? fm.attributesOfItem(atPath: url.path)[.size]) as? Int,
size > rotateBytes
{
let previous = directory.appendingPathComponent("ContextSwitchCanary.previous.jsonl")
try? fm.removeItem(at: previous)
try? fm.moveItem(at: url, to: previous)
}
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
guard var data = try? encoder.encode(event) else { return }
data.append(0x0A)
if !fm.fileExists(atPath: url.path) { fm.createFile(atPath: url.path, contents: nil) }
guard let handle = try? FileHandle(forWritingTo: url) else { return }
defer { try? handle.close() }
_ = try? handle.seekToEnd()
try? handle.write(contentsOf: data)
}
}
@@ -0,0 +1,241 @@
import Foundation
/// Portable context handed to the incoming engine. The provenance is retained in memory so the
/// coordinator and tests can distinguish an agent-authored brief from the deterministic fallback;
/// the Markdown itself remains harness-neutral.
public struct HandoverBrief: Sendable, Equatable {
public enum Provenance: Sendable, Equatable {
case outgoingAgent(model: String)
case digest
}
public var markdown: String
public var provenance: Provenance
public init(markdown: String, provenance: Provenance) {
self.markdown = markdown
self.provenance = provenance
}
}
/// Pluggable authorship seam for Context Switch. Detection, UI, and engine swapping depend only on
/// the resulting portable brief, so a future side summarizer can replace the outgoing-agent turn
/// without changing the rest of the flow.
public protocol HandoverComposing: Sendable {
func compose(
session: Session,
controller: SessionController,
events: [AgentEvent],
target: ContextSwitchTarget
) async -> HandoverBrief
}
/// Canonical machine-authored prompt for the outgoing agent's visible sign-off turn.
public enum ContextSwitchHandoverPrompt {
public static func brief(for target: ContextSwitchTarget) -> String {
"""
[Nucleic] You are handing this work over to a different AI agent \
(\(target.resolution.model)) that has no access to this conversation. Write a complete \
handover brief in plain Markdown. Do not use any tools — write only from what you already \
know. Cover: (1) the original goal and the current state; (2) what was done — files \
created/changed, by path, and why; (3) key decisions and their rationale, and approaches \
you ruled out; (4) known issues, failing tests, and dead ends; (5) the exact next steps; \
(6) anything the next agent must not redo or break. Plain portable prose only: no tool \
names, session IDs, internal reasoning fragments, or references to this interface.
"""
}
}
/// Instant, deterministic fallback used whenever the outgoing agent cannot safely author a brief.
/// It is intentionally useful on its own: callers may choose this composer directly for sessions
/// that cannot run another turn.
public struct DigestHandover: HandoverComposing {
/// Current session-associated todo text known by the coordinator. Core has no global todo
/// registry, so the app injects these structured facts when available.
public var todoItems: [String]
public init(todoItems: [String] = []) {
self.todoItems = todoItems
}
public func compose(
session: Session,
controller _: SessionController,
events: [AgentEvent],
target: ContextSwitchTarget
) async -> HandoverBrief {
Self.brief(session: session, events: events, target: target, todoItems: todoItems)
}
public static func brief(
session: Session,
events: [AgentEvent],
target: ContextSwitchTarget,
todoItems: [String] = []
) -> HandoverBrief {
let userRequests = events.compactMap { event -> String? in
guard case .userText(let chunk) = event.kind else { return nil }
let text = chunk.text.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
let originalGoal = userRequests.first ?? session.title
let digest = HeuristicSummary.sessionDigest(events)
let lastTurn = HeuristicSummary.lastTurn(events)
var changedFiles: [(path: String, kind: FileChange.ChangeKind)] = []
var seenPaths = Set<String>()
for event in events.reversed() {
guard case .fileChange(let change) = event.kind,
seenPaths.insert(change.path).inserted
else { continue }
changedFiles.append((change.path, change.kind))
}
changedFiles.reverse()
var lines = [
"# Handover brief",
"",
"> Auto-generated digest (the previous agent could not write a brief).",
"",
"## Original goal and current state",
"",
originalGoal,
"",
"The chat is switching from \(target.fromPurpose.displayName) to "
+ "\(target.resolution.purpose.displayName), targeting "
+ "`\(target.resolution.model)` at `\(target.resolution.effort)` effort.",
"",
digest,
"",
"## Files changed",
"",
]
if changedFiles.isEmpty {
lines.append("No per-file change events were recorded.")
} else {
for file in changedFiles {
lines.append("- `\(file.path)` — \(file.kind.rawValue)")
}
}
if let stat = session.diffStat {
lines.append("")
lines.append(
"Recorded diffstat: \(stat.filesChanged) files, +\(stat.added)/-\(stat.removed).")
}
lines += ["", "## Current to-do state", ""]
if todoItems.isEmpty {
lines.append("No session-associated open to-do items were recorded.")
} else {
for item in todoItems { lines.append("- \(item)") }
}
lines += ["", "## Most recent settled turn", ""]
if let request = lastTurn.request?.trimmingCharacters(in: .whitespacesAndNewlines),
!request.isEmpty
{
lines.append("Latest request: \(request)")
}
if let reply = lastTurn.reply?.trimmingCharacters(in: .whitespacesAndNewlines),
!reply.isEmpty
{
lines.append("")
lines.append("Latest agent reply: \(reply)")
}
lines += [
"",
"## Next steps",
"",
"Continue from the current repository state, verify this digest against the code, "
+ "and complete the newest request without redoing work already reflected in the diff.",
]
return HandoverBrief(markdown: lines.joined(separator: "\n"), provenance: .digest)
}
}
/// v1 handover author: one cache-hot, tool-free turn on the outgoing engine, guarded by a strict
/// abort ladder. Every failure returns the deterministic digest; callers never receive an empty
/// brief or need to understand why generation failed.
public struct OutgoingAgentHandover: HandoverComposing {
private enum MonitorResult: Sendable {
case completed(String)
case abort
}
public var timeoutSeconds: TimeInterval
public var interruptGraceSeconds: TimeInterval
private let fallback: any HandoverComposing
public init(
fallback: any HandoverComposing = DigestHandover(),
timeoutSeconds: TimeInterval = 120,
interruptGraceSeconds: TimeInterval = 5
) {
self.fallback = fallback
self.timeoutSeconds = timeoutSeconds
self.interruptGraceSeconds = interruptGraceSeconds
}
public func compose(
session: Session,
controller: SessionController,
events: [AgentEvent],
target: ContextSwitchTarget
) async -> HandoverBrief {
let currentEvents = await controller.transcriptSoFar()
let baseline = currentEvents.count
do {
try await controller.sendInput(
AgentInput(text: ContextSwitchHandoverPrompt.brief(for: target)),
echoToTranscript: false)
} catch {
return await fallback.compose(
session: session, controller: controller, events: events, target: target)
}
let stream = await controller.subscribe(afterHistoryCount: baseline)
let monitor = Task<MonitorResult, Never> {
var finalReply: String?
for await event in stream {
if Task.isCancelled { return .abort }
switch event.kind {
case .assistantText(let chunk) where !chunk.isPartial:
let text = chunk.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty { finalReply = text }
case .toolCallStarted, .approvalRequested:
return .abort
case .runFinished(let finished):
guard finished.outcome == .completed else { return .abort }
let reply = finalReply
?? finished.finalText?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let reply, !reply.isEmpty else { return .abort }
return .completed(reply)
default:
continue
}
}
return .abort
}
let result = await withTimeout(timeoutSeconds) { await monitor.value }
guard let result, case .completed(let markdown) = result else {
monitor.cancel()
await controller.interrupt()
_ = await withTimeout(interruptGraceSeconds) {
await controller.join()
return true
}
return await fallback.compose(
session: session,
controller: controller,
events: await controller.transcriptSoFar(),
target: target)
}
await controller.join()
return HandoverBrief(
markdown: markdown,
provenance: .outgoingAgent(model: session.model ?? session.backend.rawValue))
}
}
@@ -230,6 +230,25 @@ public actor SessionController {
}
}
/// Replay events appended after a caller's captured history count, then stay attached for new
/// events if the controller is still live. This closes the otherwise unavoidable race between
/// starting a machine-injected turn and attaching a monitor: a very fast backend may finish
/// before the second actor hop, but its complete turn is still replayed here.
public func subscribe(afterHistoryCount count: Int) -> AsyncStream<AgentEvent> {
let id = UUID()
let start = min(max(0, count), history.count)
let replay = Array(history.dropFirst(start))
let ended = fanoutFinished
return AsyncStream { continuation in
for event in replay { continuation.yield(event) }
guard !ended else { return continuation.finish() }
subscribers[id] = continuation
continuation.onTermination = { [weak self] _ in
Task { await self?.removeSubscriber(id) }
}
}
}
private func removeSubscriber(_ id: UUID) {
subscribers[id] = nil
}
@@ -76,8 +76,9 @@ public struct WireModelCatalog: Sendable, Codable, Equatable {
return allModels.first { $0.sku == sku }
}
/// SKUs that run on `backend` for the in-session model menu, where the backend is fixed
/// at creation. Returns the SKUs grouped contiguously by provider, flattened.
/// SKUs that run on `backend` for the in-session model menu, keyed by the session summary's
/// current backend. (An accepted Context Switch can replace the creation-time backend.)
/// Returns the SKUs grouped contiguously by provider, flattened.
public func models(for backend: BackendID) -> [Model] {
allModels.filter { model in
switch backend {
@@ -35,6 +35,18 @@ struct PurposeMLClassifierTests {
}
}
private actor CountingIntelligence: IntelligenceProviding {
private(set) var calls = 0
func summarize(session: Session, events: [AgentEvent]) async -> String { "" }
func sessionName(fromFirstMessage message: String) async -> String? { nil }
func classifyPromptPurpose(prompt: String) async -> PurposeVerdict {
calls += 1
return PurposeVerdict(
purpose: .debugging, confidence: .high, source: .afm,
reason: "must not run for Context Switch")
}
}
private struct FixedIntelligence: IntelligenceProviding {
let verdict: PurposeVerdict
func summarize(session: Session, events: [AgentEvent]) async -> String { "" }
@@ -183,6 +195,23 @@ struct PurposeMLClassifierTests {
#expect(service.previewState == .pending)
}
@Test @MainActor func contextSwitchDraftScopeNeverReachesAFMOrEscalation() async {
let local = PurposeVerdict(
purpose: .frontendImpl, confidence: .medium, source: .custom,
reason: "bundled local model")
let intelligence = CountingIntelligence()
let service = PromptPurposeService(
intelligence: intelligence,
modelClassifier: FixedModel(verdict: local),
escalation: intelligence,
classificationScope: .localOnly)
let prompt = "Please consider the requested behavior across the application"
#expect(service.prepareDraftChange(prompt))
#expect(await service.draftChanged(prompt) == local)
#expect(await intelligence.calls == 0)
}
@Test @MainActor func newChatLaunchUsesOnlyTheResolvedDisplayedPair() throws {
let verdict = PurposeVerdict(
purpose: .review, confidence: .high, source: .heuristic,
@@ -8,6 +8,22 @@ private struct FixedPurposeModel: PurposeModelClassifying {
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
@@ -472,5 +488,263 @@ struct ContextSwitchAppStoreTests {
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)
}
}
@@ -100,6 +100,17 @@ private func simpleTurn(_ e: ScriptedBackend.Emitter) {
@Suite("SessionController — single-writer pipeline", .isolatedContainerSettings)
struct SessionControllerTests {
private func handoverTarget() -> ContextSwitchTarget {
ContextSwitchTarget(
fromPurpose: .backendImpl,
level: .deep,
backend: .codex,
resolution: IntelligenceRouter.Resolution(
model: "gpt-5.6-sol", effort: "xhigh",
purpose: .frontendImpl, level: .deep,
reason: "Frontend implementation · Deep"))
}
@Test func assignsCanonicalSeqAndReachesFinished() async throws {
let backend = ScriptedBackend { e, _ in simpleTurn(e) }
let (controller, _, cleanup) = try makeController(backend: backend)
@@ -904,6 +915,169 @@ struct SessionControllerTests {
#expect(header.backend == .claudeCode)
}
@Test func outgoingAgentHandoverCapturesVisibleToolFreeBrief() async throws {
let turn = LockedBox(0)
let backend = ScriptedBackend { emitter, _ in
let current = turn.get() + 1
turn.set(current)
emitter.emit(.sessionStarted(SessionStarted(
backendSessionID: "old-native", model: "claude-opus-5", cwd: "/tmp",
toolNames: [])))
let reply = current == 1
? "The API implementation is complete."
: "# Current state\n\nAPI work is complete. Next, build the settings UI."
emitter.emit(.assistantText(TextChunk(
messageID: "reply-\(current)", text: reply, isPartial: false)))
emitter.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
emitter.emit(.runFinished(RunFinished(outcome: .completed)))
}
let (controller, _, cleanup) = try makeController(
backend: backend, conversational: true)
defer { cleanup() }
await controller.start(prompt: AgentInput(text: "Implement the API"))
await controller.join()
let session = await controller.snapshot.session
let before = await controller.transcriptSoFar()
let brief = await OutgoingAgentHandover().compose(
session: session, controller: controller, events: before,
target: handoverTarget())
#expect(brief.provenance == .outgoingAgent(model: "claude-opus-5"))
#expect(brief.markdown.contains("Next, build the settings UI"))
#expect(backend.lastResume?.prompt?.plainText?.contains(
"Do not use any tools") == true)
#expect(backend.lastResume?.prompt?.plainText?.contains("gpt-5.6-sol") == true)
let visibleUserText = await controller.transcriptSoFar().compactMap { event -> String? in
if case .userText(let chunk) = event.kind { return chunk.text }
return nil
}
#expect(visibleUserText == ["Implement the API"])
await controller.shutdown()
}
@Test func outgoingAgentHandoverAbortLadderUsesDigest() async throws {
let modes = ["tool", "approval", "errored", "maxTurns"]
for mode in modes {
let turn = LockedBox(0)
let backend = ScriptedBackend { emitter, _ in
let current = turn.get() + 1
turn.set(current)
emitter.emit(.sessionStarted(SessionStarted(
backendSessionID: "old-native", model: "claude-opus-5", cwd: "/tmp",
toolNames: [])))
if current == 1 {
emitter.emit(.fileChange(FileChange(
path: "Sources/API.swift", kind: .update)))
emitter.emit(.assistantText(TextChunk(
messageID: "initial", text: "API work is complete.", isPartial: false)))
emitter.emit(.runFinished(RunFinished(outcome: .completed)))
return
}
switch mode {
case "tool":
emitter.emit(.toolCallStarted(ToolCall(
toolCallID: "tool", name: "Read", input: .object([:]))))
emitter.emit(.runFinished(RunFinished(outcome: .completed)))
case "approval":
emitter.emit(.approvalRequested(ApprovalRequest(
id: .generate(), sessionID: emitter.sessionID,
toolCallID: "tool", toolName: "Write", input: .object([:]),
title: "Write a file", risk: .write, createdAt: fixedNow)))
emitter.emit(.runFinished(RunFinished(outcome: .completed)))
case "errored":
emitter.emit(.runFinished(RunFinished(outcome: .errored)))
default:
emitter.emit(.runFinished(RunFinished(outcome: .maxTurns)))
}
}
let (controller, _, cleanup) = try makeController(
backend: backend, conversational: true)
await controller.start(prompt: AgentInput(text: "Implement the API"))
await controller.join()
let brief = await OutgoingAgentHandover().compose(
session: await controller.snapshot.session,
controller: controller,
events: await controller.transcriptSoFar(),
target: handoverTarget())
#expect(brief.provenance == .digest, "abort mode: \(mode)")
#expect(brief.markdown.contains("Auto-generated digest"), "abort mode: \(mode)")
#expect(brief.markdown.contains("Sources/API.swift"), "abort mode: \(mode)")
await controller.shutdown()
cleanup()
}
}
@Test func outgoingAgentHandoverTimeoutUsesDigestWithoutWaitingForAgent() async throws {
let turn = LockedBox(0)
let backend = ScriptedBackend { emitter, _ in
let current = turn.get() + 1
turn.set(current)
emitter.emit(.sessionStarted(SessionStarted(
backendSessionID: "old-native", model: "claude-opus-5", cwd: "/tmp",
toolNames: [])))
if current == 1 {
emitter.emit(.assistantText(TextChunk(
messageID: "initial", text: "API work is complete.", isPartial: false)))
emitter.emit(.runFinished(RunFinished(outcome: .completed)))
} else {
try? await Task.sleep(for: .seconds(30))
}
}
let (controller, _, cleanup) = try makeController(
backend: backend, conversational: true)
defer { cleanup() }
await controller.start(prompt: AgentInput(text: "Implement the API"))
await controller.join()
let brief = await OutgoingAgentHandover(
timeoutSeconds: 0.02, interruptGraceSeconds: 0.02
).compose(
session: await controller.snapshot.session,
controller: controller,
events: await controller.transcriptSoFar(),
target: handoverTarget())
#expect(brief.provenance == .digest)
#expect(brief.markdown.contains("Auto-generated digest"))
await controller.shutdown()
}
@Test func digestHandoverIncludesStructuredSessionFacts() async throws {
var session = Session(
id: .generate(), projectID: .generate(), backend: .codex,
title: "API migration", model: "gpt-5.6-sol", effort: "xhigh",
transcriptPath: "/tmp/digest.jsonl", createdAt: fixedNow, updatedAt: fixedNow)
session.diffStat = DiffStat(filesChanged: 2, added: 40, removed: 7)
let events = [
AgentEvent(
sessionID: session.id, seq: 1, at: fixedNow, backend: .codex,
nativeType: nil,
kind: .userText(TextChunk(
messageID: "user", text: "Migrate the API", isPartial: false))),
AgentEvent(
sessionID: session.id, seq: 2, at: fixedNow, backend: .codex,
nativeType: nil,
kind: .fileChange(FileChange(path: "Sources/API.swift", kind: .update))),
AgentEvent(
sessionID: session.id, seq: 3, at: fixedNow, backend: .codex,
nativeType: nil,
kind: .assistantText(TextChunk(
messageID: "reply", text: "Migration is partly complete.", isPartial: false))),
]
let brief = DigestHandover.brief(
session: session, events: events, target: handoverTarget(),
todoItems: ["Add migration coverage"])
#expect(brief.provenance == .digest)
#expect(brief.markdown.contains("Migrate the API"))
#expect(brief.markdown.contains("`Sources/API.swift` — update"))
#expect(brief.markdown.contains("2 files, +40/-7"))
#expect(brief.markdown.contains("Add migration coverage"))
}
@Test func switchEngineRejectsAnUnsettledOrTerminalSession() async throws {
let heldBackend = GatedTurnBackend()
let replacement = ScriptedBackend { _, _ in }
@@ -623,6 +623,10 @@ import Testing
sku: "claude-opus-4-8", displayName: "Opus 4.8", backend: .claudeCode,
contextBadge: "256K", contextWindow: 256_000,
efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort")],
[WireModelCatalog.Model(
sku: "gpt-5.5", displayName: "GPT-5.5", backend: .codex,
contextBadge: nil, contextWindow: 400_000,
efforts: ["low", "medium", "high", "xhigh"], effortNoun: "Reasoning")],
[WireModelCatalog.Model(
sku: "grok-build", displayName: "Grok Build", backend: .grok,
contextBadge: nil, contextWindow: 256_000,
@@ -679,6 +683,30 @@ import Testing
#expect(back.queuedMessages.first?.attachments.first?.filename == "shot.png")
}
@Test func sessionUpdateCarriesCurrentBackendAndRekeysModelCatalog() throws {
let switched = SessionSummary(
sessionID: summary.sessionID, projectID: summary.projectID,
projectName: summary.projectName,
backend: .codex, status: .awaitingInput,
title: summary.title, branch: summary.branch, lastSeq: summary.lastSeq + 1,
diffStat: summary.diffStat,
model: "gpt-5.5", effort: "xhigh",
routedLevel: 3, routedPurpose: PromptPurpose.backendImpl.rawValue,
updatedAt: summary.updatedAt.addingTimeInterval(1))
guard case .sessionUpdated(let decoded) = try roundTrip(
HostMsg.sessionUpdated(switched))
else {
Issue.record("sessionUpdated changed wire kind")
return
}
#expect(decoded.backend == .codex)
#expect(decoded.model == "gpt-5.5")
#expect(catalog.models(for: decoded.backend).map(\.sku) == ["gpt-5.5"])
#expect(catalog.models(for: .claudeCode).map(\.sku)
!= catalog.models(for: decoded.backend).map(\.sku))
}
@Test func sessionSummaryToleratesMissingNewFields() throws {
// A summary encoded by a host that predates the model/effort/auto/autoShip fields:
// it must decode with safe defaults rather than throwing (SYNC §9 forward-compat).
+13 -11
View File
@@ -1,10 +1,12 @@
# CONTEXT_SWITCH — mid-chat model handover when the work's domain shifts
> **Status:** implementation in progress, 2026-08-04. Work items 13 are implemented with
> focused tests: the mutable engine/switch seam, persisted per-chat mute, offer policy and
> throttle, and the local-only turn-end drift watcher. Work item 4 (draft-time watcher) is
> next. This document remains the
> self-contained specification. Sibling of docs/PURPOSE_CLASSIFIER.md (whose layered
> **Status:** v1 runtime implementation complete and dark-shipped, 2026-08-04. The runtime
> portions of work items 19 are implemented with focused core/app/wire tests plus macOS
> and iOS builds. The kill switch intentionally remains default-false until canary review;
> that review and the resulting default-on decision are rollout operations, not missing
> runtime machinery. Work item 10 remains on the parallel classifier-training roadmap and
> can improve drift quality without changing this runtime contract. This document remains
> the self-contained specification. Sibling of docs/PURPOSE_CLASSIFIER.md (whose layered
> classifier stack and Intelligence-routing matrix this feature rides) and
> docs/DONE_CLASSIFIER.md (whose turn-end hook discipline it mirrors). An agent picking up
> any work item in §11 should be able to execute it from this document plus the referenced
@@ -555,9 +557,9 @@ per-backend container split on; iPhone shows the updated model/backend after a s
`DigestHandover` on error states.
- **Cross-harness always-allow rules** silently stop matching after a lane change —
the user re-answers a few prompts. Acceptable; revisit if it reads as a regression.
- **Sync assumptions**: any host/remote surface that treats `session.backend` as
immutable (the iOS model picker does — §7) must be audited; the wire field itself is
already per-summary.
- **Sync assumptions**: the wire summary already carries the current backend, and the iOS
model picker is now re-keyed from that field. New remote surfaces must preserve that
per-update behavior rather than caching a session's initial backend.
- **Open:** should acceptance be offerable from the dashboard card / ephemeral window
(v1: session detail only)? Should a switch be undoable ("switch back" reusing the same
machinery with the purposes reversed — cheap to add since the old native session is
@@ -580,6 +582,6 @@ per-backend container split on; iPhone shows the updated model/backend after a s
| 9 | iOS/wire audit | 6 | backend-field sync verification, remote model-picker re-key off current backend |
| 10 | Classifier extension (parallel track) | ml pipeline | assistant-prose dataset slice per PURPOSE_CLASSIFIER §4.1 conventions; mixed-intent consumption when purpose-deep ships |
Items 12 are pure core work and unblock everything; 35 parallelize after 2; 8 gates the
default; 10 rides the existing `ml/purpose-classifier/` roadmap and improves verdicts
without runtime changes.
Implementation status (2026-08-04): the runtime portions of items 19 are complete. Item
8's canary review still gates changing the default from false to true. Item 10 rides the
existing `ml/purpose-classifier/` roadmap and improves verdicts without runtime changes.
@@ -352,10 +352,13 @@ struct SessionDetailView: View {
@ViewBuilder
private func modelEffortMenu(_ summary: WireSessionSummary) -> some View {
if !store.routesIntelligence {
// A session's backend is fixed at creation, so only same-backend models are offered.
// Context Switch can replace the backend between turns. The summary is the live source
// of truth, and the explicit identity rebuilds a native Menu that happened to be open
// across the update instead of retaining its old provider's snapshot.
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
.id(summary.backend)
EffortMenu(effort: effortBinding, catalog: store.modelCatalog,
// No explicit model yet use the session backend's default, so the menu
// No explicit model yet use the current backend's default, so the menu
// shows that backend's effort range rather than collapsing to one level.
modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku,
controlled: controlled,
@@ -368,7 +371,7 @@ struct SessionDetailView: View {
// MARK: - The in-session Intelligence rail
/// The stop this chat's rail sits at. The host owns it moving the rail sends the stop and
/// the host re-routes within the chat's fixed backend lane, then broadcasts the resulting
/// the host re-routes within the chat's current backend lane, then broadcasts the resulting
/// model/effort so the committed value is *derived* from the summary rather than held here.
/// `pendingIntelligence` covers only the round trip, and `intelligencePreview` the drag.
private var sessionIntelligence: Int {
@@ -433,6 +436,7 @@ struct SessionDetailView: View {
.onChange(of: summary.routedLevel) { _, _ in pendingIntelligence = nil }
.onChange(of: summary.model) { _, _ in pendingIntelligence = nil }
.onChange(of: summary.effort) { _, _ in pendingIntelligence = nil }
.onChange(of: summary.backend) { _, _ in pendingIntelligence = nil }
// A refused move every candidate for that stop is inside a reached quota window
// changes nothing at all; the host answers with an error instead. Without this the
// rail would stay parked on a stop the chat never reached, so it springs back here
@@ -477,8 +477,10 @@ extension WireModelCatalog {
/// A compact model selector driven by the wire catalog (SYNC §5.2). `backend == nil` the
/// home composer, where the model also selects the backend shows every provider group with
/// dividers; a fixed `backend` (in-session, where the backend is immutable) shows only that
/// provider's models. Selecting sets the SKU; the binding's `nil` means "host default".
/// dividers; an in-session `backend` shows only the models for the session summary's *current*
/// provider. Context Switch may change that field between turns, so callers pass the live summary
/// rather than retaining the creation-time lane. Selecting sets the SKU; the binding's `nil` means
/// "host default".
struct ModelMenu: View {
@Binding var model: String?
let catalog: WireModelCatalog