Merge nucleic/warm-coral-viper-sn0m into dev
This commit is contained in:
@@ -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` 1–5).
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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 1–3 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 1–9 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 1–2 are pure core work and unblock everything; 3–5 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 1–9 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
|
||||
|
||||
Reference in New Issue
Block a user