Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ Icon?

# Tool caches
.impeccable/
.tokensave
56 changes: 7 additions & 49 deletions apps/mac/Sources/XBotCore/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,8 @@ public final class AppState {
public init(
engine: any EngineClient,
providers: ProviderConnectionStore = .shared,
appUpdates: any AppUpdateControlling = DisabledAppUpdateController.shared,
conversationStore: @escaping @Sendable () -> ConversationStore = { .ready }
appUpdates: any AppUpdateControlling = DisabledAppUpdateController.shared
) {
self.conversationStore = conversationStore
self.engine = engine
self.runtime = nil
self.environmentFactory = nil
Expand Down Expand Up @@ -294,8 +292,6 @@ public final class AppState {
/// Injected so a test gets its own preference domain. See `ProviderConnectionStore`.
providers: ProviderConnectionStore = .shared,
appUpdates: any AppUpdateControlling = DisabledAppUpdateController.shared,
/// Defaults to the real credential, because this initializer is the production one.
conversationStore: @escaping @Sendable () -> ConversationStore = { EngineBootstrap.conversationStore },
/// Whether opening the app should bring the engine up. False by default so a test's answer
/// never depends on whether this Mac has finished onboarding; the app passes the real one.
startsEngineOnLaunch: @escaping @Sendable () -> Bool = { false },
Expand All @@ -306,7 +302,6 @@ public final class AppState {
ModelKeySync.fingerprint(of: $0, keyEncryptionKey: "test")
}
) {
self.conversationStore = conversationStore
self.startsEngineOnLaunch = startsEngineOnLaunch
self.modelKeys = modelKeys
self.modelKeyFingerprint = modelKeyFingerprint
Expand All @@ -332,12 +327,6 @@ public final class AppState {
/// Endpoints the person added by hand, which the agent picker offers alongside the vendors.
private let customProviders = CustomProviderStore.shared

/// Whether the engine can keep a conversation at all.
///
/// Injected rather than read from the Keychain here, for the reason `ProviderConnectionStore`
/// documents: a state object that reaches for a machine-wide store cannot be asserted without
/// the machine's contents deciding the answer.
private let conversationStore: @Sendable () -> ConversationStore
private var startsEngineOnLaunch: @Sendable () -> Bool = { false }
private var modelKeys: @Sendable () throws -> [DesiredModelKey] = { [] }
private var modelKeyFingerprint: @Sendable (String) throws -> String = {
Expand Down Expand Up @@ -504,26 +493,9 @@ public final class AppState {
engineBaseURL = endpoint.baseURL
pinnedEngineImage = await runtime?.currentImageReference.full
engineHealth = await runtime?.checkHealth()
if providers.requiresModelForComposer() {
composerBlock = .noModelConnected
} else {
/*
* The engine is up and may not be able to hold a conversation.
*
* Without a CopilotKit key it boots into local mode, where the history client
* throws past wiring — but it still starts, still answers `/health`, and still
* lists agents, so every other signal in the app says everything is fine. Saying so
* here is invariant 7: never an empty state that implies all is well.
*
* Two ways to not have one, and they need opposite sentences: nobody connected a
* key, or the Keychain would not hand over the key that is there.
*/
composerBlock = switch conversationStore() {
case .ready: nil
case .notConnected: .noConversationStore
case .unreadable: .conversationStoreUnreadable
}
}
// Conversations are kept by the engine itself (ADR-0008), so a model is the one thing a
// running engine can still be missing.
composerBlock = providers.requiresModelForComposer() ? .noModelConnected : nil
pausedWhenIdle = false
noteEngineActivity()
// Before anything can run: a message that woke the engine is about to be answered, and
Expand Down Expand Up @@ -785,7 +757,7 @@ public final class AppState {
guard let (message, channel) = messageThatWokeTheEngine else { return }
messageThatWokeTheEngine = nil
guard composerBlock == nil, modelKeySyncProblem == nil, !isSyncingModelKeys else {
// It woke into something that needs the person — no model, no CopilotKit key. The
// It woke into something that needs the person — no model, say. The
// message is kept and made retryable, carrying the same sentence the composer shows.
let reason = composerBlock?.sentence ?? modelKeySyncProblem ?? String(localized: "Couldn't send that message.")
mark(message.id, as: .failed(reason: reason), in: channel)
Expand Down Expand Up @@ -928,16 +900,6 @@ public final class AppState {
}
try? EngineTokenStore.remove()
try? KeyEncryptionKeyStore.remove()
/*
* The CopilotKit key and its licence token, which this list used to miss.
*
* The comment above promises every key this app has written, and the one real third-party
* credential among them was not on it — so after Uninstall, the person's CopilotKit key sat
* on in the login Keychain for an app that was gone. Both are removed now; the token is
* generated per install and meaningless without the app, the key is theirs.
*/
try? IntelligenceCredentialStore.removeAPIKey()
try? IntelligenceCredentialStore.removeLicenseToken()

EngineUpdateCheckStore.reset()
AppUpdateCheckStore.reset()
Expand Down Expand Up @@ -971,13 +933,9 @@ public final class AppState {
startEngine()
case .humanHoldsControl:
setControl(.agent)
case .noModelConnected, .noConversationStore:
// Both are fixed in the same place, and settings are in this window now.
case .noModelConnected:
// Settings are in this window now.
isShowingSettings = true
case .conversationStoreUnreadable:
// Nothing to open — the key is already there. Ask the Keychain again, which is what a
// locked one or a dismissed prompt needs, and let the engine come back up with it.
startEngine()
case .runtimeUnavailable, nil:
// Runtime install is M6.
break
Expand Down
76 changes: 0 additions & 76 deletions apps/mac/Sources/XBotCore/ConversationStoreSettings.swift

This file was deleted.

48 changes: 8 additions & 40 deletions apps/mac/Sources/XBotCore/EngineBootstrap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,23 @@ public enum EngineBootstrap {
/// Environment block the container receives. Port and host gateway vary per start.
public static func environmentFactory(
keyEncryptionKey: @escaping @Sendable () -> String = { (try? KeyEncryptionKeyStore.key()) ?? "" },
engineToken: @escaping @Sendable () -> String = { (try? EngineTokenStore.token()) ?? "" },
intelligence: @escaping @Sendable () -> EngineEnvironment.Intelligence? = { IntelligenceCredentialStore.settings() }
engineToken: @escaping @Sendable () -> String = { (try? EngineTokenStore.token()) ?? "" }
) -> @Sendable (UInt16, String) -> [String: String] {
let physicalMemory = ProcessInfo.processInfo.physicalMemory
/*
* Read at each start, never captured when the closure is built.
*
* The app builds this closure before onboarding has collected anything, so capturing the
* keys here handed the engine whatever the Keychain held at launch — on a first run, nothing.
* The CopilotKit key in particular: nil means the engine boots into local mode, whose client
* throws past wiring, and that is why `conversationStore` exists — so the app can say so
* rather than let a conversation fail with nothing to read.
*/
// Read at each start, never captured when the closure is built: the app builds this
// closure before onboarding has collected anything, so capturing the keys here would hand
// the engine whatever the Keychain held at launch — on a first run, nothing.
//
// No `intelligence` is ever passed: since ADR-0008 the engine keeps conversations itself
// (`LocalThreadRunner`), so the four INTELLIGENCE_* variables stay unset and the engine
// picks local history on its own. See `EngineEnvironment.Inputs` if that ever changes.
return { port, hostGateway in
EngineEnvironment.compose(
EngineEnvironment.Inputs(
port: port,
keyEncryptionKey: keyEncryptionKey(),
hostGateway: hostGateway,
appOrigin: "xbot://app",
intelligence: intelligence(),
maxBrowsers: EngineEnvironment.browserLimit(forPhysicalMemory: physicalMemory),
engineToken: engineToken()
)
Expand All @@ -50,32 +46,4 @@ public enum EngineBootstrap {
}
)
}

/// Whether conversations can work at all.
///
/// ADR-0007 keeps CopilotKit Intelligence for v1, so without its key `runtimeCapabilities()`
/// picks local mode and `LocalIntelligence` — a spike that throws past wiring. An engine in that
/// state starts and answers `/health` and looks entirely well, which is exactly why the app has
/// to check rather than wait for a turn to fail — and three states rather than a Bool, because a
/// key nobody connected and a key the Keychain will not hand over need opposite sentences.
public static var conversationStore: ConversationStore {
switch IntelligenceCredentialStore.availability() {
case .connected: .ready
case .notConnected: .notConnected
case .unreadable: .unreadable
}
}


}

/// Whether the engine can keep a conversation, and if not, why not.
///
/// Its own type rather than a `Bool` because the two ways of not having a key need opposite
/// sentences: nobody connected one, or the Keychain would not hand over the one that is there. A
/// Bool told the second person to go and connect a key they could see in Settings.
public enum ConversationStore: Sendable, Equatable {
case ready
case notConnected
case unreadable
}
99 changes: 0 additions & 99 deletions apps/mac/Sources/XBotCore/IntelligenceCredentialStore.swift

This file was deleted.

15 changes: 13 additions & 2 deletions apps/mac/Sources/XBotEngine/HTTPEngineClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ public actor HTTPEngineClient: EngineClient {
else { return [] }

let (threadData, response) = try await send(
request(.get, "/api/copilotkit/threads?threadId=\(threadId)")
request(.get, Self.threadMessagesPath(threadId))
)
guard (response as? HTTPURLResponse)?.statusCode == 200 else { return [] }
guard
Expand Down Expand Up @@ -588,9 +588,20 @@ public actor HTTPEngineClient: EngineClient {
continuation.finish()
}

/// The runtime's route for one thread's messages.
///
/// Not `/threads?threadId=`, which is what this used to ask: the runtime matches that as the
/// thread *list*, which answers `{ threads }` — or a 400 without an agent id — and never
/// `{ messages }`. Every history read came back empty, so a conversation was blank after a
/// restart and every agent forgot everything said before the newest message.
static func threadMessagesPath(_ threadId: String) -> String {
let encoded = threadId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? threadId
return "/api/copilotkit/threads/\(encoded)/messages"
}

/// The thread's messages as AG-UI messages, or none for a thread that does not exist yet.
private func threadMessages(_ threadId: String) async throws -> [WireMessage] {
let (data, response) = try await send(request(.get, "/api/copilotkit/threads?threadId=\(threadId)"))
let (data, response) = try await send(request(.get, Self.threadMessagesPath(threadId)))
guard (response as? HTTPURLResponse)?.statusCode == 200,
let thread = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let rows = thread["messages"] as? [[String: Any]]
Expand Down
Loading
Loading