Merge pull request '2.5.2' (#12) from 2.5.2 into main

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
2026-08-17 08:37:04 +02:00
38 changed files with 9710 additions and 861 deletions
+2 -2
View File
@@ -388,7 +388,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.5.1;
MARKETING_VERSION = 2.5.2;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -440,7 +440,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.5.1;
MARKETING_VERSION = 2.5.2;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
+6000 -92
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -69,7 +69,7 @@ struct JarvisAgentInput: Codable, Sendable {
struct JarvisAgentRun: Identifiable, Codable, Sendable {
let id: String
let agentId: String?
let status: String // "running" | "completed" | "failed" | "stopped"
let status: String // "running" | "success" | "failed"/"error" | "stopped" (server-observed; not formally documented)
let startedAt: String?
let finishedAt: String?
let output: String?
@@ -80,10 +80,11 @@ struct JarvisAgentRun: Identifiable, Codable, Sendable {
let triggerType: String?
enum CodingKeys: String, CodingKey {
case id, status, output, error
case id, status, error
case agentId = "agent_id"
case startedAt = "started_at"
case finishedAt = "finished_at"
case finishedAt = "ended_at"
case output = "result"
case costUsd = "cost_usd"
case inputTokens = "input_tokens"
case outputTokens = "output_tokens"
+104
View File
@@ -25,6 +25,7 @@ import Foundation
struct UsageStats: Sendable {
var totalMessages: Int
var totalQuestions: Int
var totalTokens: Int
var totalCost: Double
var hasCostData: Bool
@@ -33,6 +34,7 @@ struct UsageStats: Sendable {
nonisolated init(
totalMessages: Int = 0,
totalQuestions: Int = 0,
totalTokens: Int = 0,
totalCost: Double = 0.0,
hasCostData: Bool = false,
@@ -40,6 +42,7 @@ struct UsageStats: Sendable {
lastMessageDate: Date? = nil
) {
self.totalMessages = totalMessages
self.totalQuestions = totalQuestions
self.totalTokens = totalTokens
self.totalCost = totalCost
self.hasCostData = hasCostData
@@ -66,6 +69,7 @@ struct ModelUsageStat: Identifiable, Sendable {
var id: String { modelId }
let modelId: String
var messageCount: Int
var questionCount: Int
var totalTokens: Int
var totalCost: Double
var hasCostData: Bool
@@ -74,6 +78,7 @@ struct ModelUsageStat: Identifiable, Sendable {
nonisolated init(
modelId: String,
messageCount: Int,
questionCount: Int = 0,
totalTokens: Int,
totalCost: Double,
hasCostData: Bool,
@@ -81,6 +86,7 @@ struct ModelUsageStat: Identifiable, Sendable {
) {
self.modelId = modelId
self.messageCount = messageCount
self.questionCount = questionCount
self.totalTokens = totalTokens
self.totalCost = totalCost
self.hasCostData = hasCostData
@@ -102,6 +108,74 @@ struct ModelUsageStat: Identifiable, Sendable {
}
}
/// Usage grouped by provider (openrouter/anthropic/openai/ollama/appleOnDevice), from `usage_events`.
struct ProviderUsageStat: Identifiable, Sendable {
var id: String { provider }
let provider: String
var questionCount: Int
var totalTokens: Int
var totalCost: Double
var hasCostData: Bool
var lastUsed: Date
nonisolated init(
provider: String,
questionCount: Int,
totalTokens: Int,
totalCost: Double,
hasCostData: Bool,
lastUsed: Date
) {
self.provider = provider
self.questionCount = questionCount
self.totalTokens = totalTokens
self.totalCost = totalCost
self.hasCostData = hasCostData
self.lastUsed = lastUsed
}
var totalTokensDisplay: String {
if totalTokens >= 1_000_000 {
return String(format: "%.1fM", Double(totalTokens) / 1_000_000)
} else if totalTokens >= 1000 {
return String(format: "%.1fK", Double(totalTokens) / 1000)
} else {
return "\(totalTokens)"
}
}
var totalCostDisplay: String {
hasCostData ? String(format: "$%.4f", totalCost) : "N/A"
}
}
/// One day's usage totals, for time-series charting in the Analytics view.
struct DailyUsageStat: Identifiable, Sendable {
var id: Date { day }
let day: Date
var messageCount: Int
var questionCount: Int
var totalTokens: Int
var totalCost: Double
var hasCostData: Bool
nonisolated init(
day: Date,
messageCount: Int,
questionCount: Int,
totalTokens: Int,
totalCost: Double,
hasCostData: Bool
) {
self.day = day
self.messageCount = messageCount
self.questionCount = questionCount
self.totalTokens = totalTokens
self.totalCost = totalCost
self.hasCostData = hasCostData
}
}
struct ConversationUsageStat: Identifiable, Sendable {
let conversationId: UUID
var id: UUID { conversationId }
@@ -141,3 +215,33 @@ struct ConversationUsageStat: Identifiable, Sendable {
hasCostData ? String(format: "$%.4f", totalCost) : "N/A"
}
}
/// Time window for the Analytics view. `.total` means all-time (no lower bound).
enum AnalyticsTimeframe: String, CaseIterable, Identifiable, Sendable {
case today
case last7Days
case week
case month
case year
case total
var id: String { rawValue }
/// Bounds for this timeframe. `start` is nil for `.total` (all time, no lower bound).
nonisolated func dateRange(now: Date = Date(), calendar: Calendar = .current) -> (start: Date?, end: Date) {
switch self {
case .today:
return (calendar.startOfDay(for: now), now)
case .last7Days:
return (calendar.date(byAdding: .day, value: -7, to: now) ?? now, now)
case .week:
return (calendar.dateInterval(of: .weekOfYear, for: now)?.start ?? now, now)
case .month:
return (calendar.dateInterval(of: .month, for: now)?.start ?? now, now)
case .year:
return (calendar.dateInterval(of: .year, for: now)?.start ?? now, now)
case .total:
return (nil, now)
}
}
}
+12 -8
View File
@@ -223,28 +223,32 @@ struct StreamChunk {
// MARK: - Tool Definition
struct Tool: Codable {
// Plain data types for JSON tool-schema encoding marked `nonisolated` throughout (each nested
// type independently, since `-default-isolation=MainActor` doesn't cascade isolation-freedom from
// an outer type to its nested declarations) so they stay usable from `nonisolated` contexts like
// ExternalMCPManager's pure conversion helpers, not just from the main actor.
nonisolated struct Tool: Codable {
let type: String
let function: Function
struct Function: Codable {
nonisolated struct Function: Codable {
let name: String
let description: String
let parameters: Parameters
struct Parameters: Codable {
nonisolated struct Parameters: Codable {
let type: String
let properties: [String: Property]
let required: [String]?
struct Property: Codable {
nonisolated struct Property: Codable {
let type: String
let description: String
let `enum`: [String]?
let items: Items?
/// Item schema for `type: "array"` properties (e.g. an array of strings).
struct Items: Codable {
nonisolated struct Items: Codable {
let type: String
}
+6 -41
View File
@@ -34,40 +34,20 @@ class AnthropicProvider: AIProvider {
maxContextLength: nil
)
enum AuthMode {
case apiKey(String)
case oauth
}
private let authMode: AuthMode
private let apiKey: String
private let baseURL = "https://api.anthropic.com/v1"
private let apiVersion = "2023-06-01"
private let session: URLSession
/// Create with a standard API key
init(apiKey: String) {
self.authMode = .apiKey(apiKey)
self.apiKey = apiKey
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 180 // 3 minutes for initial response (tool use needs thinking time)
config.timeoutIntervalForResource = 600 // 10 minutes total
self.session = URLSession(configuration: config)
}
/// Create with OAuth (Pro/Max subscription)
init(oauth: Bool) {
self.authMode = .oauth
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 180 // 3 minutes for initial response (tool use needs thinking time)
config.timeoutIntervalForResource = 600 // 10 minutes total
self.session = URLSession(configuration: config)
}
/// Whether this provider is using OAuth authentication
var isOAuth: Bool {
if case .oauth = authMode { return true }
return false
}
// MARK: - Models
/// Local metadata used to enrich API results (pricing, context length) and as offline fallback.
@@ -555,29 +535,14 @@ class AnthropicProvider: AIProvider {
// MARK: - Auth Helpers
/// Apply auth headers based on mode (API key or OAuth Bearer)
/// Apply auth headers
private func applyAuth(to request: inout URLRequest) async throws {
switch authMode {
case .apiKey(let key):
request.addValue(key, forHTTPHeaderField: "x-api-key")
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
case .oauth:
let token = try await AnthropicOAuthService.shared.getValidAccessToken()
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
request.addValue("oauth-2025-04-20,interleaved-thinking-2025-05-14", forHTTPHeaderField: "anthropic-beta")
}
request.addValue(apiKey, forHTTPHeaderField: "x-api-key")
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
}
/// Build the messages endpoint URL, appending ?beta=true for OAuth
private var messagesURL: URL {
switch authMode {
case .apiKey:
return URL(string: "\(baseURL)/messages")!
case .oauth:
return URL(string: "\(baseURL)/messages?beta=true")!
}
URL(string: "\(baseURL)/messages")!
}
// MARK: - Request Building
+13 -2
View File
@@ -47,11 +47,18 @@ struct OpenRouterChatRequest: Codable {
let modalities: [String]?
let reasoning: ReasoningAPIConfig?
let cacheControl: CacheControl?
let usage: UsageOptions?
struct CacheControl: Codable {
let type: String
}
/// Requests OpenRouter to include the actual billed USD cost in the response's `usage`
/// object needed for models priced outside plain per-token rates (e.g. per-image).
struct UsageOptions: Codable {
let include: Bool
}
struct APIMessage: Codable {
let role: String
let content: MessageContent
@@ -141,6 +148,7 @@ struct OpenRouterChatRequest: Codable {
case toolChoice = "tool_choice"
case modalities
case reasoning
case usage
case cacheControl = "cache_control"
}
}
@@ -230,6 +238,9 @@ struct OpenRouterChatResponse: Codable {
let completionTokens: Int
let totalTokens: Int
let promptTokensDetails: PromptTokensDetails?
/// Actual billed USD cost only present when the request opted in via `usage.include`.
/// Needed for models priced outside plain per-token rates (e.g. per-image generation).
let cost: Double?
struct PromptTokensDetails: Codable {
let cachedTokens: Int?
@@ -246,6 +257,7 @@ struct OpenRouterChatResponse: Codable {
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
case promptTokensDetails = "prompt_tokens_details"
case cost
}
}
}
@@ -485,10 +497,9 @@ struct OpenRouterImageGenerationResponse: Codable {
struct OpenRouterErrorResponse: Codable {
let error: ErrorDetail
struct ErrorDetail: Codable {
let message: String
let type: String?
let code: String?
}
}
+18 -6
View File
@@ -276,7 +276,8 @@ class OpenRouterProvider: AIProvider {
var body: [String: Any] = [
"model": model,
"messages": messages,
"stream": false
"stream": false,
"usage": ["include": true]
]
if let tools = tools {
let toolsData = try JSONEncoder().encode(tools)
@@ -347,8 +348,16 @@ class OpenRouterProvider: AIProvider {
}
guard httpResponse.statusCode == 200 else {
Log.api.error("OpenRouter stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
var errorBody = ""
for try await line in bytes.lines { errorBody += line }
if let errorData = errorBody.data(using: .utf8),
let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: errorData) {
Log.api.error("OpenRouter stream HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
continuation.finish(throwing: ProviderError.unknown(errorResponse.error.message))
} else {
Log.api.error("OpenRouter stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
}
return
}
@@ -497,7 +506,8 @@ class OpenRouterProvider: AIProvider {
toolChoice: request.tools != nil ? "auto" : nil,
modalities: request.imageGeneration ? ["text", "image"] : nil,
reasoning: reasoningConfig,
cacheControl: cacheControl
cacheControl: cacheControl,
usage: OpenRouterChatRequest.UsageOptions(include: true)
)
}
@@ -536,7 +546,8 @@ class OpenRouterProvider: AIProvider {
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens,
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens,
rawCostUSD: usage.cost
)
},
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
@@ -577,7 +588,8 @@ class OpenRouterProvider: AIProvider {
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens,
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens,
rawCostUSD: usage.cost
)
}
)
+1 -2
View File
@@ -101,8 +101,7 @@ class ProviderRegistry {
case .openrouter:
return settings.openrouterAPIKey != nil && !settings.openrouterAPIKey!.isEmpty
case .anthropic:
return AnthropicOAuthService.shared.isAuthenticated
|| (settings.anthropicAPIKey != nil && !settings.anthropicAPIKey!.isEmpty)
return settings.anthropicAPIKey != nil && !settings.anthropicAPIKey!.isEmpty
case .openai:
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
case .ollama:
@@ -1838,7 +1838,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
</main>
<footer>
<p>© 2026 Confab - Rune Olsen. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a> or <a href="mailto:support@fubar.pm?subject=Confab Support&body=What can I help you with?">Contact Us</a>.</p>
<p>&copy; 2025 - <span id="year"></span> <script>document.getElementById('year').textContent = new Date().getFullYear();</script> Confab - Rune Olsen. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a> or <a href="https://confab.no/#contact">Contact Us</a>.</p>
</footer>
</div>
-314
View File
@@ -1,314 +0,0 @@
//
// AnthropicOAuthService.swift
// Confab
//
// OAuth 2.0 PKCE flow for Anthropic Pro/Max subscription login
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use including selling Confab or any part of
// it, standalone or bundled into another product or service
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
import Foundation
import CryptoKit
import Security
@Observable
class AnthropicOAuthService {
static let shared = AnthropicOAuthService()
// OAuth configuration (matches Claude Code CLI)
private let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
private let redirectURI = "https://console.anthropic.com/oauth/code/callback"
private let scope = "org:create_api_key user:profile user:inference"
private let tokenEndpoint = "https://console.anthropic.com/v1/oauth/token"
// Keychain keys
private enum Keys {
static let accessToken = "com.oai.anthropic.oauth.accessToken"
static let refreshToken = "com.oai.anthropic.oauth.refreshToken"
static let expiresAt = "com.oai.anthropic.oauth.expiresAt"
}
// PKCE state for current flow
private var currentVerifier: String?
// Observable state
var isAuthenticated: Bool { accessToken != nil }
var isLoggingIn = false
// MARK: - Token Access
var accessToken: String? {
getKeychainValue(for: Keys.accessToken)
}
private var refreshToken: String? {
getKeychainValue(for: Keys.refreshToken)
}
private var expiresAt: Date? {
guard let str = getKeychainValue(for: Keys.expiresAt),
let interval = Double(str) else { return nil }
return Date(timeIntervalSince1970: interval)
}
var isTokenExpired: Bool {
guard let expires = expiresAt else { return true }
return Date() >= expires
}
// MARK: - Step 1: Generate Authorization URL
func generateAuthorizationURL() -> URL {
let verifier = generateCodeVerifier()
currentVerifier = verifier
let challenge = generateCodeChallenge(from: verifier)
var components = URLComponents(string: "https://claude.ai/oauth/authorize")!
components.queryItems = [
URLQueryItem(name: "code", value: "true"),
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "redirect_uri", value: redirectURI),
URLQueryItem(name: "scope", value: scope),
URLQueryItem(name: "code_challenge", value: challenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
URLQueryItem(name: "state", value: verifier),
]
return components.url!
}
// MARK: - Step 2: Exchange Code for Tokens
func exchangeCode(_ pastedCode: String) async throws {
guard let verifier = currentVerifier else {
throw OAuthError.noVerifier
}
// Code format: "auth_code#state"
let parts = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "#")
let authCode: String
let state: String
if parts.count >= 2 {
authCode = parts[0]
state = parts.dropFirst().joined(separator: "#")
} else {
// If no # separator, treat entire string as the code
authCode = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines)
state = verifier
}
Log.api.info("Exchanging OAuth code for tokens")
let body: [String: String] = [
"code": authCode,
"state": state,
"grant_type": "authorization_code",
"client_id": clientId,
"redirect_uri": redirectURI,
"code_verifier": verifier,
]
let tokenResponse = try await postTokenRequest(body)
saveTokens(tokenResponse)
currentVerifier = nil
Log.api.info("OAuth login successful, token expires in \(tokenResponse.expiresIn)s")
}
// MARK: - Token Refresh
func refreshAccessToken() async throws {
guard let refresh = refreshToken else {
throw OAuthError.noRefreshToken
}
Log.api.info("Refreshing OAuth access token")
let body: [String: String] = [
"grant_type": "refresh_token",
"refresh_token": refresh,
"client_id": clientId,
]
let tokenResponse = try await postTokenRequest(body)
saveTokens(tokenResponse)
Log.api.info("OAuth token refreshed successfully")
}
/// Returns a valid access token, refreshing if needed
func getValidAccessToken() async throws -> String {
guard let token = accessToken else {
throw OAuthError.notAuthenticated
}
if isTokenExpired {
try await refreshAccessToken()
guard let newToken = accessToken else {
throw OAuthError.notAuthenticated
}
return newToken
}
return token
}
// MARK: - Logout
func logout() {
deleteKeychainValue(for: Keys.accessToken)
deleteKeychainValue(for: Keys.refreshToken)
deleteKeychainValue(for: Keys.expiresAt)
currentVerifier = nil
Log.api.info("OAuth logout complete")
}
// MARK: - PKCE Helpers
private func generateCodeVerifier() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
return Data(bytes).base64URLEncoded()
}
private func generateCodeChallenge(from verifier: String) -> String {
let data = Data(verifier.utf8)
let hash = SHA256.hash(data: data)
return Data(hash).base64URLEncoded()
}
// MARK: - Token Request
private func postTokenRequest(_ body: [String: String]) async throws -> TokenResponse {
var request = URLRequest(url: URL(string: tokenEndpoint)!)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw OAuthError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
Log.api.error("OAuth token exchange failed HTTP \(httpResponse.statusCode): \(errorBody)")
throw OAuthError.tokenExchangeFailed(httpResponse.statusCode, errorBody)
}
return try JSONDecoder().decode(TokenResponse.self, from: data)
}
// MARK: - Token Storage
private func saveTokens(_ response: TokenResponse) {
setKeychainValue(response.accessToken, for: Keys.accessToken)
if let refresh = response.refreshToken {
setKeychainValue(refresh, for: Keys.refreshToken)
}
let expiresAt = Date().addingTimeInterval(TimeInterval(response.expiresIn))
setKeychainValue(String(expiresAt.timeIntervalSince1970), for: Keys.expiresAt)
}
// MARK: - Keychain Helpers
private func getKeychainValue(for key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var ref: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &ref) == errSecSuccess,
let data = ref as? Data,
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value
}
private func setKeychainValue(_ value: String, for key: String) {
guard let data = value.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
let attrs: [String: Any] = [kSecValueData as String: data]
let status = SecItemUpdate(query as CFDictionary, attrs as CFDictionary)
if status == errSecItemNotFound {
var newItem = query
newItem[kSecValueData as String] = data
SecItemAdd(newItem as CFDictionary, nil)
}
}
private func deleteKeychainValue(for key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
// MARK: - Types
struct TokenResponse: Decodable {
let accessToken: String
let refreshToken: String?
let expiresIn: Int
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case expiresIn = "expires_in"
}
}
enum OAuthError: LocalizedError {
case noVerifier
case noRefreshToken
case notAuthenticated
case invalidResponse
case tokenExchangeFailed(Int, String)
var errorDescription: String? {
switch self {
case .noVerifier: return "No PKCE verifier — start the login flow first."
case .noRefreshToken: return "No refresh token available. Please log in again."
case .notAuthenticated: return "Not authenticated. Please log in."
case .invalidResponse: return "Invalid response from Anthropic OAuth server."
case .tokenExchangeFailed(let code, let body):
return "Token exchange failed (HTTP \(code)): \(body)"
}
}
}
}
// MARK: - Base64URL Encoding
private extension Data {
func base64URLEncoded() -> String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
+271
View File
@@ -0,0 +1,271 @@
//
// CLIServerService.swift
// Confab
//
// Local Unix-socket server for one-shot shell/CLI access to a single fixed model
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use including selling Confab or any part of
// it, standalone or bundled into another product or service
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
import Foundation
import Network
import os
/// Local Unix-domain-socket server exposing one-shot, non-streaming text completions from a
/// single fixed model (Settings MCP CLI Access) e.g. an `ai "prompt"` shell function.
/// Deliberately minimal for v1: no tool-calling, no streaming, no per-request model override.
/// Speaks a minimal HTTP/1.1 subset (just enough for `curl --unix-socket`) rather than a custom
/// protocol, so it stays curl-friendly and trivially extensible new fields can be added to the
/// JSON request/response bodies later without changing the transport.
final class CLIServerService {
static let shared = CLIServerService()
private init() {}
private let log = Logger(subsystem: Log.subsystem, category: "cli")
/// All listener/connection callbacks run on this single serial queue, so `activeConnections`
/// never needs a lock Network.framework callbacks don't run on the main thread.
private let queue = DispatchQueue(label: "com.oai.Confab.cliserver")
private var listener: NWListener?
private var isListenerReady = false
private var activeConnections: [ObjectIdentifier: NWConnection] = [:]
/// How long to wait for `NWListener` to report `.ready` before giving up. See `scheduleBindTimeout`.
private static let bindTimeout: TimeInterval = 8
static let socketPath: String = {
(("~/Library/Application Support/oAI" as NSString).expandingTildeInPath as NSString)
.appendingPathComponent("cli.sock")
}()
// MARK: - Lifecycle
func start() {
guard SettingsService.shared.cliServerEnabled else {
log.info("CLI server disabled")
return
}
startListening()
}
func stop() {
listener?.cancel()
listener = nil
for (_, connection) in activeConnections { connection.cancel() }
activeConnections.removeAll()
try? FileManager.default.removeItem(atPath: Self.socketPath)
log.info("CLI server stopped")
}
/// Call after toggling cliServerEnabled or changing the provider/model, so the change takes
/// effect immediately instead of requiring an app relaunch.
func restart() {
stop()
start()
}
private func startListening() {
// Remove a stale socket file left behind by an unclean shutdown (bind() fails on an
// existing path even if nothing is listening on it anymore).
try? FileManager.default.removeItem(atPath: Self.socketPath)
isListenerReady = false
let params = NWParameters()
params.defaultProtocolStack.transportProtocol = NWProtocolTCP.Options()
params.requiredLocalEndpoint = NWEndpoint.unix(path: Self.socketPath)
params.allowLocalEndpointReuse = true
do {
let newListener = try NWListener(using: params)
newListener.newConnectionHandler = { [weak self] connection in
self?.handle(connection: connection)
}
newListener.stateUpdateHandler = { [weak self] state in
switch state {
case .ready:
self?.isListenerReady = true
self?.log.info("CLI server listening at \(Self.socketPath)")
case .failed(let error):
self?.log.error("CLI server listener failed: \(error.localizedDescription)")
self?.listener = nil
default:
break
}
}
newListener.start(queue: queue)
listener = newListener
scheduleBindTimeout(for: newListener)
} catch {
log.error("Failed to start CLI server: \(error.localizedDescription)")
}
}
/// `NWListener`'s state transitions are asynchronous and normally reach `.ready` or `.failed`
/// within milliseconds but a real incident (2026-08-14) showed a state where `lsof`
/// confirmed Confab held the socket open, yet every connection attempt was refused and
/// nothing was ever logged, with no crash and no error. Whatever the exact cause, a listener
/// that never resolves either way is indistinguishable from a working one without this: it
/// bounds the wait so a stuck bind becomes a visible log line and a cleared listener slot,
/// instead of silently pretending to work forever.
private func scheduleBindTimeout(for candidate: NWListener) {
queue.asyncAfter(deadline: .now() + Self.bindTimeout) { [weak self] in
guard let self, self.listener === candidate, !self.isListenerReady else { return }
self.log.error("CLI server did not become ready within \(Int(Self.bindTimeout))s — giving up")
candidate.cancel()
self.listener = nil
}
}
// MARK: - Connection handling
private func handle(connection: NWConnection) {
let id = ObjectIdentifier(connection)
activeConnections[id] = connection
connection.stateUpdateHandler = { [weak self] state in
switch state {
case .failed, .cancelled:
self?.activeConnections.removeValue(forKey: id)
default:
break
}
}
connection.start(queue: queue)
readRequest(on: connection, buffer: Data())
}
private func readRequest(on connection: NWConnection, buffer: Data) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
guard let self else { return }
var buffer = buffer
if let data { buffer.append(data) }
if let body = Self.parseRequestBody(from: buffer) {
Task {
let responseData = await self.processRequest(body)
await self.sendAndClose(responseData, on: connection)
}
return
}
if isComplete || error != nil {
connection.cancel()
return
}
self.readRequest(on: connection, buffer: buffer)
}
}
private func sendAndClose(_ data: Data, on connection: NWConnection) {
let id = ObjectIdentifier(connection)
connection.send(content: data, completion: .contentProcessed { [weak self] _ in
connection.cancel()
self?.activeConnections.removeValue(forKey: id)
})
}
// MARK: - Request processing
private struct AskRequest: Decodable {
let prompt: String
}
private func processRequest(_ body: Data) async -> Data {
guard let req = try? JSONDecoder().decode(AskRequest.self, from: body),
!req.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return Self.errorResponse("Request body must be JSON: {\"prompt\": \"...\"}")
}
let settings = SettingsService.shared
guard settings.cliServerEnabled else {
return Self.errorResponse("CLI server is disabled in Settings")
}
guard let providerType = Settings.Provider(rawValue: settings.cliServerProvider),
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
return Self.errorResponse("No provider configured — set one in Settings > MCP > CLI Access")
}
guard !settings.cliServerModel.isEmpty else {
return Self.errorResponse("No model configured — set one in Settings > MCP > CLI Access")
}
do {
let request = ChatRequest(
messages: [Message(role: .user, content: req.prompt)],
model: settings.cliServerModel,
stream: false
)
let response = try await provider.chat(request: request)
return Self.successResponse(response.content)
} catch {
log.error("CLI request failed: \(error.localizedDescription)")
return Self.errorResponse(error.localizedDescription, statusCode: 500)
}
}
// MARK: - Pure helpers (unit tested see CLIServerServiceTests.swift)
struct AskResponseBody: Codable {
let response: String?
let error: String?
}
/// Parses a minimal HTTP/1.1 request out of `buffer`, returning the body once the full
/// header block and (per Content-Length) body have arrived, or nil if more data is needed.
/// Not a general-purpose HTTP parser this server only ever talks to `curl --unix-socket`,
/// so the method/path/most headers are read past rather than validated.
static func parseRequestBody(from buffer: Data) -> Data? {
let headerTerminator = Data("\r\n\r\n".utf8)
guard let headerEndRange = buffer.range(of: headerTerminator) else { return nil }
let headerData = buffer.subdata(in: buffer.startIndex..<headerEndRange.lowerBound)
guard let headerString = String(data: headerData, encoding: .utf8) else { return nil }
var contentLength = 0
for line in headerString.split(separator: "\r\n") {
let parts = line.split(separator: ":", maxSplits: 1)
guard parts.count == 2,
parts[0].trimmingCharacters(in: .whitespaces).caseInsensitiveCompare("Content-Length") == .orderedSame,
let value = Int(parts[1].trimmingCharacters(in: .whitespaces)) else { continue }
contentLength = value
}
let bodyStart = headerEndRange.upperBound
let availableBodyLength = buffer.distance(from: bodyStart, to: buffer.endIndex)
guard availableBodyLength >= contentLength else { return nil }
let bodyEnd = buffer.index(bodyStart, offsetBy: contentLength)
return buffer.subdata(in: bodyStart..<bodyEnd)
}
static func makeHTTPResponse(statusCode: Int, statusText: String, jsonBody: Data) -> Data {
let header = "HTTP/1.1 \(statusCode) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(jsonBody.count)\r\nConnection: close\r\n\r\n"
var data = Data(header.utf8)
data.append(jsonBody)
return data
}
static func successResponse(_ text: String) -> Data {
let body = (try? JSONEncoder().encode(AskResponseBody(response: text, error: nil))) ?? Data()
return makeHTTPResponse(statusCode: 200, statusText: "OK", jsonBody: body)
}
static func errorResponse(_ message: String, statusCode: Int = 400) -> Data {
let body = (try? JSONEncoder().encode(AskResponseBody(response: nil, error: message))) ?? Data()
let statusText = statusCode == 400 ? "Bad Request" : "Internal Server Error"
return makeHTTPResponse(statusCode: statusCode, statusText: statusText, jsonBody: body)
}
}
+321 -7
View File
@@ -65,6 +65,19 @@ struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
var modelId: String?
}
struct UsageEventRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "usage_events"
var id: String
var timestamp: String
var provider: String
var modelId: String
var promptTokens: Int?
var completionTokens: Int?
var cost: Double?
var conversationId: String?
}
struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "settings"
@@ -404,6 +417,26 @@ final class DatabaseService: Sendable {
}
}
migrator.registerMigration("v13") { db in
// Usage tracking independent of conversation persistence: conversation *text* is only
// ever saved when the user explicitly saves (S), but usage (tokens/cost/model/provider)
// should be recorded for every completed AI response regardless one row per finished
// generation. conversationId is a best-effort link (set only if the conversation had
// already been saved at least once at logging time) and is not a foreign key, since the
// referenced conversation may never exist.
try db.create(table: "usage_events") { t in
t.column("id", .text).primaryKey()
t.column("timestamp", .text).notNull()
t.column("provider", .text).notNull()
t.column("modelId", .text).notNull()
t.column("promptTokens", .integer)
t.column("completionTokens", .integer)
t.column("cost", .double)
t.column("conversationId", .text)
}
try db.create(index: "idx_usage_events_timestamp", on: "usage_events", columns: ["timestamp"])
}
return migrator
}
@@ -806,17 +839,44 @@ final class DatabaseService: Sendable {
// MARK: - Usage Statistics
nonisolated func getOverallUsageStats() throws -> UsageStats {
/// Builds a `WHERE`-ready timestamp range clause (empty if both bounds are nil) plus its bound arguments.
/// `baseClauses` are ANDed in ahead of the range (e.g. "modelId IS NOT NULL") so callers can share one WHERE.
private nonisolated static func timestampRangeClause(
from: Date?,
to: Date?,
baseClauses: [String] = []
) -> (whereSQL: String, arguments: [String]) {
var clauses = baseClauses
var arguments: [String] = []
if let from {
clauses.append("timestamp >= ?")
arguments.append(Self.isoString(from: from))
}
if let to {
clauses.append("timestamp <= ?")
arguments.append(Self.isoString(from: to))
}
guard !clauses.isEmpty else { return ("", []) }
return (" WHERE " + clauses.joined(separator: " AND "), arguments)
}
/// Overall usage totals. Pass `from`/`to` to restrict to a time window; omit both for all-time.
nonisolated func getOverallUsageStats(from: Date? = nil, to: Date? = nil) throws -> UsageStats {
try dbQueue.read { db in
guard let row = try Row.fetchOne(db, sql: """
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
let sql = """
SELECT COUNT(*) AS cnt,
COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) AS questions,
COALESCE(SUM(tokens), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount,
MIN(timestamp) AS minTs,
MAX(timestamp) AS maxTs
FROM messages
""")
\(whereSQL)
"""
guard let row = try Row.fetchOne(db, sql: sql, arguments: StatementArguments(arguments))
else {
return UsageStats()
}
@@ -827,6 +887,7 @@ final class DatabaseService: Sendable {
return UsageStats(
totalMessages: row["cnt"],
totalQuestions: row["questions"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
@@ -836,19 +897,25 @@ final class DatabaseService: Sendable {
}
}
nonisolated func getUsageByModel() throws -> [ModelUsageStat] {
/// Usage grouped by model. Pass `from`/`to` to restrict to a time window; omit both for all-time.
nonisolated func getUsageByModel(from: Date? = nil, to: Date? = nil) throws -> [ModelUsageStat] {
try dbQueue.read { db in
let rows = try Row.fetchAll(db, sql: """
let (whereSQL, arguments) = Self.timestampRangeClause(
from: from, to: to, baseClauses: ["modelId IS NOT NULL"]
)
let sql = """
SELECT modelId,
COUNT(*) AS cnt,
COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) AS questions,
COALESCE(SUM(tokens), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount,
MAX(timestamp) AS lastUsed
FROM messages
WHERE modelId IS NOT NULL
\(whereSQL)
GROUP BY modelId
""")
"""
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
let stats: [ModelUsageStat] = rows.compactMap { row in
guard let modelId: String = row["modelId"],
@@ -860,6 +927,7 @@ final class DatabaseService: Sendable {
return ModelUsageStat(
modelId: modelId,
messageCount: row["cnt"],
questionCount: row["questions"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
@@ -876,6 +944,252 @@ final class DatabaseService: Sendable {
}
}
/// Usage bucketed by calendar day (in the local timezone the timestamps were stored in GMT, see
/// `isoStyle`), for the Analytics view's time-series chart. `from`/`to` bound the range; days with
/// no messages are simply absent (the view fills gaps when rendering).
nonisolated func getDailyUsage(from: Date, to: Date) throws -> [DailyUsageStat] {
try dbQueue.read { db in
let sql = """
SELECT substr(timestamp, 1, 10) AS day,
COUNT(*) AS cnt,
COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) AS questions,
COALESCE(SUM(tokens), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount
FROM messages
WHERE timestamp >= ? AND timestamp <= ?
GROUP BY day
ORDER BY day
"""
let rows = try Row.fetchAll(
db, sql: sql,
arguments: [Self.isoString(from: from), Self.isoString(from: to)]
)
var gmtCalendar = Calendar(identifier: .gregorian)
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
return rows.compactMap { row -> DailyUsageStat? in
guard let dayString: String = row["day"] else { return nil }
let parts = dayString.split(separator: "-").compactMap { Int($0) }
guard parts.count == 3 else { return nil }
var components = DateComponents()
components.year = parts[0]
components.month = parts[1]
components.day = parts[2]
guard let day = gmtCalendar.date(from: components) else { return nil }
let costCount: Int = row["costCount"]
return DailyUsageStat(
day: day,
messageCount: row["cnt"],
questionCount: row["questions"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0
)
}
}
}
// MARK: - Usage Events (save-independent usage tracking)
/// Records one completed AI generation's usage independent of whether the conversation it
/// belongs to is ever explicitly saved. `conversationId` is a best-effort link, set only if the
/// conversation had already been saved at least once when this was logged.
nonisolated func logUsageEvent(
provider: String,
modelId: String,
promptTokens: Int?,
completionTokens: Int?,
cost: Double?,
conversationId: UUID?
) throws {
let record = UsageEventRecord(
id: UUID().uuidString,
timestamp: Self.isoString(from: Date()),
provider: provider,
modelId: modelId,
promptTokens: promptTokens,
completionTokens: completionTokens,
cost: cost,
conversationId: conversationId?.uuidString
)
try dbQueue.write { db in
try record.insert(db)
}
}
/// Overall usage-event totals (each row = one completed generation = one "question").
nonisolated func getUsageEventTotals(from: Date? = nil, to: Date? = nil) throws -> UsageStats {
try dbQueue.read { db in
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
let sql = """
SELECT COUNT(*) AS cnt,
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount,
MIN(timestamp) AS minTs,
MAX(timestamp) AS maxTs
FROM usage_events
\(whereSQL)
"""
guard let row = try Row.fetchOne(db, sql: sql, arguments: StatementArguments(arguments))
else {
return UsageStats()
}
let costCount: Int = row["costCount"]
let cnt: Int = row["cnt"]
let minTs: String? = row["minTs"]
let maxTs: String? = row["maxTs"]
return UsageStats(
totalMessages: cnt,
totalQuestions: cnt,
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
firstMessageDate: minTs.flatMap { Self.isoDate(from: $0) },
lastMessageDate: maxTs.flatMap { Self.isoDate(from: $0) }
)
}
}
/// Usage events grouped by model.
nonisolated func getUsageEventsByModel(from: Date? = nil, to: Date? = nil) throws -> [ModelUsageStat] {
try dbQueue.read { db in
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
let sql = """
SELECT modelId,
COUNT(*) AS cnt,
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount,
MAX(timestamp) AS lastUsed
FROM usage_events
\(whereSQL)
GROUP BY modelId
"""
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
let stats: [ModelUsageStat] = rows.compactMap { row in
guard let modelId: String = row["modelId"],
let lastUsedString: String = row["lastUsed"],
let lastUsed = Self.isoDate(from: lastUsedString)
else { return nil }
let costCount: Int = row["costCount"]
let cnt: Int = row["cnt"]
return ModelUsageStat(
modelId: modelId,
messageCount: cnt,
questionCount: cnt,
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
lastUsed: lastUsed
)
}
return stats.sorted { lhs, rhs in
if lhs.hasCostData || rhs.hasCostData {
return lhs.totalCost > rhs.totalCost
}
return lhs.totalTokens > rhs.totalTokens
}
}
}
/// Usage events grouped by provider.
nonisolated func getUsageEventsByProvider(from: Date? = nil, to: Date? = nil) throws -> [ProviderUsageStat] {
try dbQueue.read { db in
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
let sql = """
SELECT provider,
COUNT(*) AS cnt,
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount,
MAX(timestamp) AS lastUsed
FROM usage_events
\(whereSQL)
GROUP BY provider
"""
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
let stats: [ProviderUsageStat] = rows.compactMap { row in
guard let provider: String = row["provider"],
let lastUsedString: String = row["lastUsed"],
let lastUsed = Self.isoDate(from: lastUsedString)
else { return nil }
let costCount: Int = row["costCount"]
return ProviderUsageStat(
provider: provider,
questionCount: row["cnt"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
lastUsed: lastUsed
)
}
return stats.sorted { lhs, rhs in
if lhs.hasCostData || rhs.hasCostData {
return lhs.totalCost > rhs.totalCost
}
return lhs.totalTokens > rhs.totalTokens
}
}
}
/// Usage events bucketed by calendar day (GMT, matching `isoStyle`), for the Analytics view.
nonisolated func getDailyUsageEvents(from: Date, to: Date) throws -> [DailyUsageStat] {
try dbQueue.read { db in
let sql = """
SELECT substr(timestamp, 1, 10) AS day,
COUNT(*) AS cnt,
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount
FROM usage_events
WHERE timestamp >= ? AND timestamp <= ?
GROUP BY day
ORDER BY day
"""
let rows = try Row.fetchAll(
db, sql: sql,
arguments: [Self.isoString(from: from), Self.isoString(from: to)]
)
var gmtCalendar = Calendar(identifier: .gregorian)
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
return rows.compactMap { row -> DailyUsageStat? in
guard let dayString: String = row["day"] else { return nil }
let parts = dayString.split(separator: "-").compactMap { Int($0) }
guard parts.count == 3 else { return nil }
var components = DateComponents()
components.year = parts[0]
components.month = parts[1]
components.day = parts[2]
guard let day = gmtCalendar.date(from: components) else { return nil }
let costCount: Int = row["costCount"]
let cnt: Int = row["cnt"]
return DailyUsageStat(
day: day,
messageCount: cnt,
questionCount: cnt,
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0
)
}
}
}
nonisolated func getUsageByConversation(limit: Int = 20) throws -> [ConversationUsageStat] {
try dbQueue.read { db in
let rows = try Row.fetchAll(db, sql: """
+44 -164
View File
@@ -5,22 +5,18 @@ import Foundation
// MARK: - ExternalMCPClient
/// Manages one MCP stdio server process. All state is MainActor-isolated
/// (consistent with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor project setting).
/// Background I/O runs in Task.detached; state mutations hop back to MainActor.
/// Owns one MCP server connection's lifecycle and JSON-RPC message framing. Delivery (stdio
/// subprocess vs Streamable HTTP) is delegated to a `MCPTransport` this class only builds
/// envelopes, decodes typed results, and tracks connection state.
/// All state is MainActor-isolated (consistent with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
/// project setting).
@MainActor
final class ExternalMCPClient {
let server: ExternalMCPServer
weak var stateDelegate: (any ExternalMCPStateDelegate)?
private var process: Process?
private var stdinHandle: FileHandle?
private var readTask: Task<Void, Never>?
private var stderrTask: Task<Void, Never>?
private let transport: any MCPTransport
private var nextRequestId: Int = 1
private var pendingCalls: [Int: CheckedContinuation<Data, Error>] = [:]
private var lineBuffer = Data()
private(set) var state: MCPClientState = .idle
private(set) var discoveredTools: [MCPToolDefinition] = []
@@ -28,6 +24,31 @@ final class ExternalMCPClient {
init(server: ExternalMCPServer, stateDelegate: (any ExternalMCPStateDelegate)?) {
self.server = server
self.stateDelegate = stateDelegate
let stdioTransport: StdioMCPTransport?
switch server.transportKind {
case .stdio:
let t = StdioMCPTransport(server: server)
stdioTransport = t
self.transport = t
case .http:
stdioTransport = nil
self.transport = HTTPMCPTransport(server: server)
}
// `self` is only safe to capture once every stored property above has a value
// wire the crash callback here, after `init` would otherwise be considered complete.
stdioTransport?.onTerminated = { [weak self] in
self?.handleTransportTerminatedUnexpectedly()
}
}
/// Called by a stdio transport whose subprocess died on its own as opposed to a
/// deliberate `stop()` call, or a failure already handled inline within `start()`.
/// No HTTP equivalent: a Streamable HTTP connection has no persistent process to crash;
/// its failures surface per-request instead (handled in `start()`/`callTool()` directly).
private func handleTransportTerminatedUnexpectedly() {
guard state != .stopped else { return }
state = .crashed
stateDelegate?.clientDidChangeState(id: server.id, state: .crashed)
}
// MARK: - Lifecycle
@@ -37,54 +58,28 @@ final class ExternalMCPClient {
state = .connecting
stateDelegate?.clientDidChangeState(id: server.id, state: .connecting)
let proc = Process()
if server.command.hasPrefix("/") {
proc.executableURL = URL(fileURLWithPath: server.command)
proc.arguments = server.args
} else {
proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
proc.arguments = [server.command] + server.args
}
proc.environment = ProcessInfo.processInfo.environment
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
proc.standardInput = stdinPipe
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
proc.terminationHandler = { [weak self] _ in
Task { @MainActor [weak self] in self?.handleProcessTerminated() }
}
do {
try proc.run()
} catch {
state = .error(error.localizedDescription)
stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription))
throw MCPClientError.processLaunchFailed(error.localizedDescription)
}
try await transport.prepare()
process = proc
stdinHandle = stdinPipe.fileHandleForWriting
startReadLoop(pipe: stdoutPipe)
startStderrLoop(pipe: stderrPipe)
do {
let _: MCPInitializeResult = try await timedRequest(seconds: 15, method: "initialize", params: [
"protocolVersion": "2024-11-05",
"capabilities": [:] as [String: Any],
"clientInfo": ["name": "Confab", "version": "1.0"] as [String: Any]
])
try sendNotification(method: "notifications/initialized")
try await transport.sendNotification(["jsonrpc": "2.0", "method": "notifications/initialized"])
let toolsResult: MCPToolsListResult = try await timedRequest(seconds: 15, method: "tools/list", params: nil)
discoveredTools = toolsResult.tools
} catch {
state = .error(error.localizedDescription)
stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription))
proc.terminate()
// Uniformly route every start() failure (bad config, launch failure, handshake
// failure for either transport) through .crashed, not .error, so
// ExternalMCPManager's restart-with-backoff drives from exactly one place.
// (Previously, stdio relied on the subprocess's termination handler firing
// asynchronously to reach .crashed; that path doesn't exist for HTTP, so failures
// there would otherwise get stuck at .error with no retry.)
state = .crashed
transport.stop()
stateDelegate?.clientDidChangeState(id: server.id, state: .crashed)
throw error
}
@@ -94,14 +89,7 @@ final class ExternalMCPClient {
func stop() {
state = .stopped
readTask?.cancel()
stderrTask?.cancel()
process?.terminate()
process = nil
stdinHandle = nil
lineBuffer = Data()
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
pendingCalls.removeAll()
transport.stop()
}
// MARK: - Tool Execution
@@ -128,125 +116,17 @@ final class ExternalMCPClient {
}
}
// MARK: - I/O Loops (detached from MainActor)
private func startReadLoop(pipe: Pipe) {
readTask = Task.detached { [weak self] in
let handle = pipe.fileHandleForReading
while true {
let data = handle.availableData
if data.isEmpty { break }
await self?.receiveData(data)
}
}
}
private func startStderrLoop(pipe: Pipe) {
let name = server.name
stderrTask = Task.detached {
let handle = pipe.fileHandleForReading
var buf = Data()
while true {
let data = handle.availableData
if data.isEmpty { break }
buf.append(data)
while let idx = buf.firstIndex(of: UInt8(ascii: "\n")) {
let line = String(data: buf[buf.startIndex..<idx], encoding: .utf8) ?? ""
buf = Data(buf[buf.index(after: idx)...])
if !line.trimmingCharacters(in: .whitespaces).isEmpty {
Log.extMcp.warning("[\(name)] \(line)")
}
}
}
}
}
// MARK: - Data Processing (MainActor)
private func receiveData(_ data: Data) {
lineBuffer.append(data)
while let idx = lineBuffer.firstIndex(of: UInt8(ascii: "\n")) {
let lineData = Data(lineBuffer[lineBuffer.startIndex..<idx])
lineBuffer = Data(lineBuffer[lineBuffer.index(after: idx)...])
processLine(lineData)
}
}
private func processLine(_ data: Data) {
guard !data.isEmpty,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let id = json["id"] as? Int,
let cont = pendingCalls.removeValue(forKey: id) else { return }
if let err = json["error"] as? [String: Any] {
cont.resume(throwing: MCPClientError.invalidResponse(err["message"] as? String ?? "Unknown error"))
} else if let result = json["result"],
let resultData = try? JSONSerialization.data(withJSONObject: result) {
cont.resume(returning: resultData)
} else {
cont.resume(throwing: MCPClientError.invalidResponse("Missing result field"))
}
}
// MARK: - JSON-RPC
/// Send a JSON-RPC request with a per-call timeout. The timeout fires a cancellation
/// directly into the pending-calls table rather than using a task group (which would
/// pass the generic T through a @Sendable closure and trigger an isolated-conformance warning).
private func timedRequest<T: Decodable>(seconds: Double, method: String, params: [String: Any]?) async throws -> T {
let id = nextRequestId
nextRequestId += 1
var message: [String: Any] = ["jsonrpc": "2.0", "method": method, "id": id]
if let params { message["params"] = params }
try writeJSON(message)
// Schedule timeout: cancels the specific pending call by ID
let timeoutId = id
Task { [weak self, timeoutId] in
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
self?.cancelPendingCall(id: timeoutId, with: MCPClientError.timeout)
}
// Await response data, then decode on MainActor
let resultData: Data = try await withCheckedThrowingContinuation { cont in
pendingCalls[id] = cont
}
let resultData = try await transport.sendRequest(message, id: id, timeoutSeconds: seconds)
return try JSONDecoder().decode(T.self, from: resultData)
}
private func cancelPendingCall(id: Int, with error: Error) {
pendingCalls.removeValue(forKey: id)?.resume(throwing: error)
}
private func sendNotification(method: String) throws {
try writeJSON(["jsonrpc": "2.0", "method": method])
}
private func writeJSON(_ message: [String: Any]) throws {
guard let handle = stdinHandle, process?.isRunning == true else {
throw MCPClientError.writeFailed
}
guard let data = try? JSONSerialization.data(withJSONObject: message),
let line = String(data: data, encoding: .utf8) else {
throw MCPClientError.writeFailed
}
do {
try handle.write(contentsOf: Data((line + "\n").utf8))
} catch {
throw MCPClientError.writeFailed
}
}
// MARK: - Process termination
private func handleProcessTerminated() {
guard state != .stopped else { return }
state = .crashed
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
pendingCalls.removeAll()
stateDelegate?.clientDidChangeState(id: server.id, state: .crashed)
}
// MARK: - Result conversion
private func convertMCPResult(_ result: MCPToolCallResult) -> [String: Any] {
+13 -11
View File
@@ -67,13 +67,10 @@ final class ExternalMCPManager {
Task {
do {
try await client.start()
} catch MCPClientError.processLaunchFailed(let msg) {
// Process never started termination handler won't fire, so manually trigger crashed
Log.extMcp.error("Failed to launch '\(server.name)': \(msg)")
clientDidChangeState(id: server.id, state: .crashed)
} catch {
// Handshake/other failure proc.terminate() was called in start(), termination
// handler will fire and set .crashed, which drives the restart from one place only.
// start() already notified stateDelegate with .crashed (uniformly, for both
// transports and every failure kind) before throwing restart-with-backoff is
// already scheduled via clientDidChangeState. Nothing further to do but log.
Log.extMcp.warning("'\(server.name)' start failed: \(error.localizedDescription)")
}
}
@@ -126,7 +123,7 @@ final class ExternalMCPManager {
private func rebuildCache(for server: ExternalMCPServer, tools: [MCPToolDefinition]) {
removeCachedSchemas(for: server.id, slug: server.slug)
let prefixed = tools.compactMap { convertToolDefinition($0, server: server) }
let prefixed = tools.compactMap { Self.convertToolDefinition($0, server: server) }
cachedToolSchemas.append(contentsOf: prefixed)
Log.extMcp.info("[\(server.name)] cached \(prefixed.count) tools: \(prefixed.map { $0.function.name }.joined(separator: ", "))")
}
@@ -140,7 +137,7 @@ final class ExternalMCPManager {
cachedToolSchemas.removeAll { $0.function.name.hasPrefix("\(slug)_") }
}
private func convertToolDefinition(_ def: MCPToolDefinition, server: ExternalMCPServer) -> Tool? {
nonisolated static func convertToolDefinition(_ def: MCPToolDefinition, server: ExternalMCPServer) -> Tool? {
Tool(
type: "function",
function: Tool.Function(
@@ -151,13 +148,18 @@ final class ExternalMCPManager {
)
}
private func convertInputSchema(_ schema: MCPInputSchema) -> Tool.Function.Parameters {
/// A schema property with no `"type"` at all is valid JSON Schema (e.g. an `enum`-only or
/// composed property) not every MCP server's tool schemas set it, so this must not assume
/// it's present. (Found via a real crash: Obsidian's Local REST API plugin sends at least one
/// tool parameter with no `type`, which a `prop.type!` force-unwrap here used to crash on.)
nonisolated static func convertInputSchema(_ schema: MCPInputSchema) -> Tool.Function.Parameters {
var properties: [String: Tool.Function.Parameters.Property] = [:]
for (key, prop) in schema.properties ?? [:] {
let effectiveType = prop.type ?? "string"
let normalized: String
switch prop.type ?? "string" {
switch effectiveType {
case "integer": normalized = "number"
case "string", "number", "boolean", "array", "object": normalized = prop.type!
case "string", "number", "boolean", "array", "object": normalized = effectiveType
default: normalized = "string"
}
var items: Tool.Function.Parameters.Property.Items? = nil
+65 -7
View File
@@ -5,26 +5,79 @@ import Foundation
// MARK: - Server Configuration
struct ExternalMCPServer: Codable, Identifiable, Sendable {
/// Which wire protocol an `ExternalMCPServer` uses. `.stdio` fields are `command`/`args`/`env`;
/// `.http` fields are `url`/`bearerToken`/`headers`. Kept as one flat struct rather than an enum
/// with associated values simpler `Codable` and simpler settings-JSON storage, at the cost of
/// each server config carrying some always-unused fields for its transport.
nonisolated enum MCPTransportKind: String, Codable, Sendable, CaseIterable {
case stdio
case http
}
nonisolated struct ExternalMCPServer: Codable, Identifiable, Sendable {
var id: UUID
var name: String
var transportKind: MCPTransportKind
var command: String
var args: [String]
var env: [String: String]
var url: String
var bearerToken: String
var headers: [String: String]
var isEnabled: Bool
var timeout: TimeInterval
var createdAt: Date
init(id: UUID = UUID(), name: String, command: String, args: [String] = [],
isEnabled: Bool = true, timeout: TimeInterval = 30, createdAt: Date = Date()) {
init(
id: UUID = UUID(),
name: String,
transportKind: MCPTransportKind = .stdio,
command: String = "",
args: [String] = [],
env: [String: String] = [:],
url: String = "",
bearerToken: String = "",
headers: [String: String] = [:],
isEnabled: Bool = true,
timeout: TimeInterval = 30,
createdAt: Date = Date()
) {
self.id = id
self.name = name
self.transportKind = transportKind
self.command = command
self.args = args
self.env = env
self.url = url
self.bearerToken = bearerToken
self.headers = headers
self.isEnabled = isEnabled
self.timeout = timeout
self.createdAt = createdAt
}
enum CodingKeys: String, CodingKey {
case id, name, transportKind, command, args, env, url, bearerToken, headers, isEnabled, timeout, createdAt
}
/// Custom decoding so servers saved before `transportKind`/`env`/`url`/`bearerToken`/`headers`
/// existed (plain stdio-only configs) still decode those keys default rather than fail.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(UUID.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
command = try c.decode(String.self, forKey: .command)
args = try c.decode([String].self, forKey: .args)
isEnabled = try c.decode(Bool.self, forKey: .isEnabled)
timeout = try c.decode(TimeInterval.self, forKey: .timeout)
createdAt = try c.decode(Date.self, forKey: .createdAt)
transportKind = try c.decodeIfPresent(MCPTransportKind.self, forKey: .transportKind) ?? .stdio
env = try c.decodeIfPresent([String: String].self, forKey: .env) ?? [:]
url = try c.decodeIfPresent(String.self, forKey: .url) ?? ""
bearerToken = try c.decodeIfPresent(String.self, forKey: .bearerToken) ?? ""
headers = try c.decodeIfPresent([String: String].self, forKey: .headers) ?? [:]
}
var slug: String { Self.makeSlug(from: name) }
static func makeSlug(from name: String) -> String {
@@ -100,6 +153,7 @@ enum MCPClientError: LocalizedError {
case processLaunchFailed(String)
case handshakeFailed(String)
case writeFailed
case invalidConfiguration(String)
var errorDescription: String? {
switch self {
@@ -109,6 +163,7 @@ enum MCPClientError: LocalizedError {
case .processLaunchFailed(let s): return "Failed to launch MCP server: \(s)"
case .handshakeFailed(let s): return "MCP handshake failed: \(s)"
case .writeFailed: return "Failed to write to MCP server stdin"
case .invalidConfiguration(let s): return "Invalid MCP server configuration: \(s)"
}
}
}
@@ -136,24 +191,27 @@ struct MCPToolsListResult: Decodable {
let nextCursor: String?
}
struct MCPToolDefinition: Decodable {
// Plain DTOs read from ExternalMCPManager.convertToolDefinition/convertInputSchema both
// `nonisolated static func` (for direct unit testing) so these must stay `nonisolated` too,
// same reasoning as `Tool` in AIProvider.swift.
nonisolated struct MCPToolDefinition: Decodable {
let name: String
let description: String?
let inputSchema: MCPInputSchema
}
struct MCPInputSchema: Decodable {
nonisolated struct MCPInputSchema: Decodable {
let type: String
let properties: [String: MCPPropertySchema]?
let required: [String]?
}
struct MCPPropertySchema: Decodable {
nonisolated struct MCPPropertySchema: Decodable {
let type: String?
let description: String?
let `enum`: [String]?
let items: MCPItemsSchema?
struct MCPItemsSchema: Decodable { let type: String? }
nonisolated struct MCPItemsSchema: Decodable { let type: String? }
}
struct MCPToolCallResult: Decodable {
+321
View File
@@ -0,0 +1,321 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Foundation
// MARK: - Transport abstraction
/// One MCP server connection's wire protocol. `ExternalMCPClient` builds JSON-RPC envelopes and
/// decodes typed results; the transport only owns *delivery* how the bytes get to the server
/// and back. A long-lived subprocess pipe for stdio, discrete HTTP requests for Streamable HTTP.
@MainActor
protocol MCPTransport: AnyObject {
/// Prepares the transport for use launches the subprocess for stdio; a no-op for HTTP,
/// since there's no persistent connection to establish ahead of the first request.
func prepare() async throws
/// Sends a JSON-RPC *request* (`message` includes `"id"`) and returns its `result` payload.
/// Throws `MCPClientError.invalidResponse` if the server returned a JSON-RPC error, or
/// `.timeout` if `timeoutSeconds` elapses first.
func sendRequest(_ message: [String: Any], id: Int, timeoutSeconds: Double) async throws -> Data
/// Sends a JSON-RPC *notification* (no `"id"`, no response expected).
func sendNotification(_ message: [String: Any]) async throws
/// Tears down the transport terminates the subprocess / drops HTTP session state.
func stop()
}
enum MCPTransportSupport {
/// Extracts the `result` payload from a decoded top-level JSON-RPC response object, shared
/// by every transport so error/result semantics stay identical regardless of wire protocol.
static func extractResult(from json: [String: Any]) throws -> Data {
if let err = json["error"] as? [String: Any] {
throw MCPClientError.invalidResponse(err["message"] as? String ?? "Unknown error")
}
guard let result = json["result"],
let resultData = try? JSONSerialization.data(withJSONObject: result)
else {
throw MCPClientError.invalidResponse("Missing result field")
}
return resultData
}
}
// MARK: - Stdio Transport
/// Launches the server as a subprocess and speaks newline-delimited JSON-RPC over its
/// stdin/stdout, exactly as the original (pre-transport-split) `ExternalMCPClient` did.
@MainActor
final class StdioMCPTransport: MCPTransport {
private let server: ExternalMCPServer
/// Set by the owner after construction (not an init parameter) the owner (`ExternalMCPClient`)
/// needs to capture `self` weakly to wire this up, which Swift only allows once `self` is
/// fully initialized, i.e. after its own `init` has finished assigning all stored properties.
var onTerminated: (() -> Void)?
private var process: Process?
private var stdinHandle: FileHandle?
private var readTask: Task<Void, Never>?
private var stderrTask: Task<Void, Never>?
private var pendingCalls: [Int: CheckedContinuation<Data, Error>] = [:]
private var lineBuffer = Data()
init(server: ExternalMCPServer) {
self.server = server
}
func prepare() async throws {
let proc = Process()
if server.command.hasPrefix("/") {
proc.executableURL = URL(fileURLWithPath: server.command)
proc.arguments = server.args
} else {
proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
proc.arguments = [server.command] + server.args
}
// Inherited environment first (so PATH etc. still resolves e.g. `npx` needs PATH to
// find node), then layer the user-configured vars over it so they can override.
var environment = ProcessInfo.processInfo.environment
for (key, value) in server.env { environment[key] = value }
proc.environment = environment
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
proc.standardInput = stdinPipe
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
proc.terminationHandler = { [weak self] _ in
Task { @MainActor [weak self] in self?.handleProcessTerminated() }
}
do {
try proc.run()
} catch {
throw MCPClientError.processLaunchFailed(error.localizedDescription)
}
process = proc
stdinHandle = stdinPipe.fileHandleForWriting
startReadLoop(pipe: stdoutPipe)
startStderrLoop(pipe: stderrPipe)
}
func sendRequest(_ message: [String: Any], id: Int, timeoutSeconds: Double) async throws -> Data {
try writeJSON(message)
// Schedule timeout: cancels the specific pending call by ID
let timeoutId = id
Task { [weak self, timeoutId] in
try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
self?.cancelPendingCall(id: timeoutId, with: MCPClientError.timeout)
}
return try await withCheckedThrowingContinuation { cont in
pendingCalls[id] = cont
}
}
func sendNotification(_ message: [String: Any]) async throws {
try writeJSON(message)
}
func stop() {
readTask?.cancel()
stderrTask?.cancel()
process?.terminate()
process = nil
stdinHandle = nil
lineBuffer = Data()
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
pendingCalls.removeAll()
}
// MARK: I/O Loops (detached from MainActor)
private func startReadLoop(pipe: Pipe) {
readTask = Task.detached { [weak self] in
let handle = pipe.fileHandleForReading
while true {
let data = handle.availableData
if data.isEmpty { break }
await self?.receiveData(data)
}
}
}
private func startStderrLoop(pipe: Pipe) {
let name = server.name
stderrTask = Task.detached {
let handle = pipe.fileHandleForReading
var buf = Data()
while true {
let data = handle.availableData
if data.isEmpty { break }
buf.append(data)
while let idx = buf.firstIndex(of: UInt8(ascii: "\n")) {
let line = String(data: buf[buf.startIndex..<idx], encoding: .utf8) ?? ""
buf = Data(buf[buf.index(after: idx)...])
if !line.trimmingCharacters(in: .whitespaces).isEmpty {
Log.extMcp.warning("[\(name)] \(line)")
}
}
}
}
}
// MARK: Data Processing (MainActor)
private func receiveData(_ data: Data) {
lineBuffer.append(data)
while let idx = lineBuffer.firstIndex(of: UInt8(ascii: "\n")) {
let lineData = Data(lineBuffer[lineBuffer.startIndex..<idx])
lineBuffer = Data(lineBuffer[lineBuffer.index(after: idx)...])
processLine(lineData)
}
}
private func processLine(_ data: Data) {
guard !data.isEmpty,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let id = json["id"] as? Int,
let cont = pendingCalls.removeValue(forKey: id) else { return }
do {
let resultData = try MCPTransportSupport.extractResult(from: json)
cont.resume(returning: resultData)
} catch {
cont.resume(throwing: error)
}
}
private func cancelPendingCall(id: Int, with error: Error) {
pendingCalls.removeValue(forKey: id)?.resume(throwing: error)
}
private func writeJSON(_ message: [String: Any]) throws {
guard let handle = stdinHandle, process?.isRunning == true else {
throw MCPClientError.writeFailed
}
guard let data = try? JSONSerialization.data(withJSONObject: message),
let line = String(data: data, encoding: .utf8) else {
throw MCPClientError.writeFailed
}
do {
try handle.write(contentsOf: Data((line + "\n").utf8))
} catch {
throw MCPClientError.writeFailed
}
}
private func handleProcessTerminated() {
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
pendingCalls.removeAll()
onTerminated?()
}
}
// MARK: - HTTP (Streamable HTTP) Transport
/// Speaks MCP's Streamable HTTP transport (spec revision 2025-06-18): every JSON-RPC message is
/// its own HTTP POST to the server's single MCP endpoint. The server may answer with a plain
/// `application/json` body, or open a `text/event-stream` (SSE) response this transport
/// supports both, but (since Confab only needs request/response tool calls, not server-initiated
/// push) does not open a standalone listening GET stream for unsolicited server messages.
@MainActor
final class HTTPMCPTransport: MCPTransport {
private let server: ExternalMCPServer
private let urlSession: URLSession
private var sessionId: String?
init(server: ExternalMCPServer, urlSession: URLSession = .shared) {
self.server = server
self.urlSession = urlSession
}
func prepare() async throws {
guard URL(string: server.url) != nil else {
throw MCPClientError.invalidConfiguration("Invalid server URL: \(server.url)")
}
// No persistent connection to establish ahead of time the first request (`initialize`)
// both opens the session and confirms the server is reachable.
}
func sendRequest(_ message: [String: Any], id: Int, timeoutSeconds: Double) async throws -> Data {
let request = try buildRequest(for: message, timeoutSeconds: timeoutSeconds)
let (data, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw MCPClientError.invalidResponse("Non-HTTP response")
}
if let newSessionId = http.value(forHTTPHeaderField: "Mcp-Session-Id") {
sessionId = newSessionId
}
guard (200...299).contains(http.statusCode) else {
throw MCPClientError.invalidResponse("HTTP \(http.statusCode)")
}
let json = try Self.parseResponseBody(
data, contentType: http.value(forHTTPHeaderField: "Content-Type"), expectedId: id
)
return try MCPTransportSupport.extractResult(from: json)
}
func sendNotification(_ message: [String: Any]) async throws {
// Notifications carry no "id", so per spec the server responds 202 Accepted, no body.
let request = try buildRequest(for: message, timeoutSeconds: 15)
let (_, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw MCPClientError.invalidResponse("Notification not accepted")
}
}
func stop() {
sessionId = nil
}
private func buildRequest(for message: [String: Any], timeoutSeconds: Double) throws -> URLRequest {
guard let url = URL(string: server.url) else {
throw MCPClientError.invalidConfiguration("Invalid server URL: \(server.url)")
}
var request = URLRequest(url: url, timeoutInterval: timeoutSeconds)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept")
request.setValue("2025-06-18", forHTTPHeaderField: "MCP-Protocol-Version")
if !server.bearerToken.isEmpty {
request.setValue("Bearer \(server.bearerToken)", forHTTPHeaderField: "Authorization")
}
for (key, value) in server.headers {
request.setValue(value, forHTTPHeaderField: key)
}
if let sessionId {
request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id")
}
request.httpBody = try JSONSerialization.data(withJSONObject: message)
return request
}
/// Parses either a direct `application/json` body, or an SSE (`text/event-stream`) body
/// scanning its `data:` lines for the JSON-RPC message whose `id` matches `expectedId` (the
/// server may send unrelated requests/notifications on the same stream first, per spec).
nonisolated static func parseResponseBody(_ data: Data, contentType: String?, expectedId: Int) throws -> [String: Any] {
if contentType?.contains("text/event-stream") == true {
guard let text = String(data: data, encoding: .utf8) else {
throw MCPClientError.invalidResponse("Non-UTF8 SSE body")
}
for line in text.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("data:") else { continue }
let payload = trimmed.dropFirst("data:".count).trimmingCharacters(in: .whitespaces)
guard let payloadData = payload.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
let id = json["id"] as? Int, id == expectedId
else { continue }
return json
}
throw MCPClientError.invalidResponse("No matching response in SSE stream")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw MCPClientError.invalidResponse("Malformed JSON response body")
}
return json
}
}
+30
View File
@@ -1043,6 +1043,36 @@ class SettingsService {
}
}
// MARK: - CLI Server
/// Local Unix-socket server (CLIServerService) for one-shot, non-streaming shell access to a
/// single fixed model deliberately separate from the chat UI's defaultProvider/defaultModel
/// so switching models in the GUI never changes shell-tool behavior, same reasoning as the
/// email handler's own dedicated provider/model pair above.
var cliServerEnabled: Bool {
get { cache["cliServerEnabled"] == "true" }
set {
cache["cliServerEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "cliServerEnabled", value: String(newValue))
}
}
var cliServerProvider: String {
get { cache["cliServerProvider"] ?? "openrouter" }
set {
cache["cliServerProvider"] = newValue
DatabaseService.shared.setSetting(key: "cliServerProvider", value: newValue)
}
}
var cliServerModel: String {
get { cache["cliServerModel"] ?? "" }
set {
cache["cliServerModel"] = newValue
DatabaseService.shared.setSetting(key: "cliServerModel", value: newValue)
}
}
var emailSubjectIdentifier: String {
get { cache["emailSubjectIdentifier"] ?? "[OAIBOT]" }
set {
+620 -5
View File
@@ -23,14 +23,44 @@
import Foundation
struct ThinkingVerbs {
/// Get a random thinking verb with ellipsis
/// Marked `nonisolated` at the type level (not per-member) since this project builds with
/// `-default-isolation=MainActor` without it, every member (including the static `let` verb
/// arrays) defaults to MainActor-isolated, which then conflicts with `nonisolated` funcs trying
/// to reference them. See CLAUDE.md's Swift 6 Compatibility section / MEMORY.md's Common Gotchas.
nonisolated struct ThinkingVerbs {
/// Get a random thinking verb with ellipsis, in the app's active display language.
/// Each language has its own hand-written set (not a translation of the English one)
/// literal translations of English wordplay ("Waking up the hamsters") often land flat
/// or sound plain odd in another language, so every list was written to be funny/natural
/// on its own terms while covering the same rough categories (classic, technical, mystical,
/// quirky, etc).
static func random() -> String {
verbs.randomElement()! + "..."
verbs(for: currentLanguageCode).randomElement()! + "..."
}
/// Collection of fun thinking verbs and phrases
private static let verbs = [
/// Mirrors how Text()/String(localized:) resolve the active language: the first of the
/// user's preferred languages that this bundle actually ships a localization for (falls
/// back to English otherwise). See CLAUDE.md's supported-language list (en, nb, sv, da, de, fr).
private static var currentLanguageCode: String {
Bundle.main.preferredLocalizations.first ?? "en"
}
/// Internal (not private) so tests can verify the languagelist mapping without depending
/// on Bundle.main's runtime localization state.
static func verbs(for languageCode: String) -> [String] {
switch languageCode {
case "nb": return nbVerbs
case "sv": return svVerbs
case "da": return daVerbs
case "de": return deVerbs
case "fr": return frVerbs
default: return enVerbs
}
}
// MARK: - English
private static let enVerbs = [
// Classic thinking
"Thinking",
"Pondering",
@@ -144,4 +174,589 @@ struct ThinkingVerbs {
"Loading brilliance",
"Unfurling wisdom"
]
// MARK: - Norwegian Bokmål
private static let nbVerbs = [
// Classic thinking
"Tenker",
"Grunner",
"Fundere",
"Reflekterer",
"Mediterer",
"Spekulerer",
"Overveier",
// Fancy/sophisticated
"Kontemplerer",
"Filosoferer",
"Resonnerer",
"Analyserer dypt",
"Bryner hjernen",
// Technical/AI themed
"Beregner",
"Prosesserer",
"Analyserer",
"Syntetiserer",
"Kalkulerer",
"Utleder",
"Kompilerer tanker",
"Kjører algoritmer",
"Knuser data",
"Parser nevroner",
// Creative/playful
"Dagdrømmer",
"Idémyldrer",
"Koker sammen tanker",
"Rører i tankegryta",
"Lar det godgjøre seg",
"Spinner nevroner",
"Varmer opp knollen",
"Klekker ut idéer",
// Mystical/fun
"Rådfører orakelet",
"Leser kaffegrut",
"Maner frem visdom",
"Trollbinder et svar",
"Kaster nevrale garn",
"Spår svaret",
// Quirky/silly
"Gjør greia",
"Trylle frem et svar",
"Aktiverer hjerneceller",
"Bøyer nevronene",
"Varmer opp transistorene",
"Rever opp synapsene",
"Kiler hjernebarken",
"Vekker hamsterne",
"Rådfører tomrommet",
"Spør den magiske 8-ballen",
// Self-aware/meta
"Later som om jeg tenker",
"Ser opptatt ut",
"Trekker ut tiden",
"Teller sauer",
"Tvinner tomlene",
"Ordner tankene",
"Leter etter riktige ord",
// Speed variations
"Tenker fort",
"Lynraskt tankearbeid",
"Dyptenkende",
"Hypertenker",
// Action-oriented
"Snekrer sammen et svar",
"Vever ord",
"Setter sammen tanker",
"Konstruerer svar",
"Formulerer idéer",
"Dirigerer nevroner",
"Koreograferer bits",
// Whimsical
"Får en åpenbaring",
"Kobler prikkene",
"Følger tråden",
"Jager tanker",
"Gjeter idéer",
"Reder ut nevronfloken",
// Time-based
"Tar en tenkepause",
"Puster rolig",
"Tar fem",
"Samler tankene",
"Henter pusten",
// Just plain weird
"Piper og beregner",
"Aktiverer hjernemodus",
"Slår på smartheten",
"Laster ned tanker",
"Bufrer intelligens",
"Laster inn genialitet",
"Folder ut visdom",
"Roter i idébanken",
"Sorterer tankene",
"Venter på et lyn av genialitet",
"Plager hjernen med det",
"Kokende av idéer",
"Grubler høyt",
"Filosoferer over saken"
]
// MARK: - Swedish
private static let svVerbs = [
// Classic thinking
"Tänker",
"Funderar",
"Grubblar",
"Reflekterar",
"Mediterar",
"Spekulerar",
"Överväger",
// Fancy/sophisticated
"Kontemplerar",
"Filosoferar",
"Resonerar",
"Djupanalyserar",
"Vässar hjärnan",
// Technical/AI themed
"Beräknar",
"Processar",
"Analyserar",
"Syntetiserar",
"Kalkylerar",
"Härleder",
"Kompilerar tankar",
"Kör algoritmer",
"Mosar data",
"Parsar nervceller",
// Creative/playful
"Dagdrömmer",
"Idékläcker",
"Kokar ihop tankar",
"Rör i tankegrytan",
"Låter det mogna",
"Snurrar nervceller",
"Värmer upp knoppen",
"Ruvar på idéer",
// Mystical/fun
"Rådfrågar oraklet",
"Läser i kaffesumpen",
"Frammanar visdom",
"Trollbinder ett svar",
"Kastar neurala nät",
"Spår svaret",
// Quirky/silly
"Gör grejen",
"Trollar fram ett svar",
"Aktiverar hjärnceller",
"Böjer nervcellerna",
"Värmer upp transistorerna",
"Varvar upp synapserna",
"Kittlar hjärnbarken",
"Väcker hamstrarna",
"Rådfrågar tomrummet",
"Frågar magiska åttan",
// Self-aware/meta
"Låtsas tänka",
"Ser upptagen ut",
"Drar ut på tiden",
"Räknar får",
"Tvinnar tummarna",
"Ordnar tankarna",
"Letar rätt ord",
// Speed variations
"Tänker snabbt",
"Blixtsnabb tankeverksamhet",
"Djuptänkande",
"Hypertänker",
// Action-oriented
"Snickrar ihop ett svar",
"Väver ord",
"Sätter ihop tankar",
"Konstruerar svar",
"Formulerar idéer",
"Dirigerar nervceller",
"Koreograferar bitar",
// Whimsical
"Får en aha-upplevelse",
"Kopplar ihop punkterna",
"Följer tråden",
"Jagar tankar",
"Vallar idéer",
"Reder ut nervtrasslet",
// Time-based
"Tar en tankepaus",
"Andas lugnt",
"Tar fem",
"Samlar tankarna",
"Hämtar andan",
// Just plain weird
"Piper och beräknar",
"Aktiverar hjärnläge",
"Slår på smartheten",
"Laddar ner tankar",
"Buffrar intelligens",
"Laddar in briljans",
"Vecklar ut visdom",
"Rotar i idébanken",
"Sorterar tankarna",
"Väntar på ett geniblixt",
"Bryr hjärnan med det",
"Kokar av idéer",
"Grubblar högt",
"Filosoferar över saken"
]
// MARK: - Danish
private static let daVerbs = [
// Classic thinking
"Tænker",
"Grunder",
"Grubler",
"Reflekterer",
"Mediterer",
"Spekulerer",
"Overvejer",
// Fancy/sophisticated
"Kontemplerer",
"Filosoferer",
"Ræsonnerer",
"Dybdeanalyserer",
"Skærper hjernen",
// Technical/AI themed
"Beregner",
"Processerer",
"Analyserer",
"Syntetiserer",
"Kalkulerer",
"Udleder",
"Kompilerer tanker",
"Kører algoritmer",
"Knuser data",
"Parser neuroner",
// Creative/playful
"Dagdrømmer",
"Idémylrer",
"Koger tanker sammen",
"Rører i tankegryden",
"Lader det simre",
"Snurrer neuroner",
"Varmer knolden op",
"Udruger idéer",
// Mystical/fun
"Rådspørger orakelet",
"Læser kaffegrums",
"Fremmaner visdom",
"Tryller et svar frem",
"Kaster neurale net",
"Spår svaret",
// Quirky/silly
"Gør tingen",
"Trylle-fremkalder et svar",
"Aktiverer hjerneceller",
"Bøjer neuronerne",
"Varmer transistorerne op",
"Ruller synapserne op",
"Kilder hjernebarken",
"Vækker hamsterne",
"Rådspørger tomrummet",
"Spørger den magiske 8-tal",
// Self-aware/meta
"Lader som om jeg tænker",
"Ser optaget ud",
"Trækker tiden ud",
"Tæller får",
"Snor tommelfingrene",
"Ordner tankerne",
"Leder efter de rette ord",
// Speed variations
"Tænker hurtigt",
"Lynhurtig tænkning",
"Dybttænkende",
"Hypertænker",
// Action-oriented
"Snedkererer et svar",
"Væver ord",
"Samler tanker",
"Konstruerer svar",
"Formulerer idéer",
"Dirigerer neuroner",
"Koreograferer bits",
// Whimsical
"Får en åbenbaring",
"Forbinder prikkerne",
"Følger tråden",
"Jagter tanker",
"Vogter idéer",
"Reder neurontrådene ud",
// Time-based
"Tager en tænkepause",
"Trækker vejret roligt",
"Tager fem",
"Samler tankerne",
"Henter vejret",
// Just plain weird
"Bipper og beregner",
"Aktiverer hjernemodus",
"Tænder for kløgt",
"Downloader tanker",
"Bufrer intelligens",
"Indlæser genialitet",
"Folder visdom ud",
"Roder i idébanken",
"Sorterer tankerne",
"Venter på et lyn af genialitet",
"Plager hjernen med det",
"Kogende af idéer",
"Grunder højt",
"Filosoferer over sagen"
]
// MARK: - German
private static let deVerbs = [
// Classic thinking
"Denkt nach",
"Grübelt",
"Sinniert",
"Reflektiert",
"Meditiert",
"Spekuliert",
"Überlegt",
// Fancy/sophisticated
"Kontempliert",
"Philosophiert",
"Räsoniert",
"Analysiert tiefgründig",
"Schärft das Denkvermögen",
// Technical/AI themed
"Berechnet",
"Verarbeitet",
"Analysiert",
"Synthetisiert",
"Kalkuliert",
"Leitet ab",
"Kompiliert Gedanken",
"Lässt Algorithmen laufen",
"Zermalmt Daten",
"Parst Neuronen",
// Creative/playful
"Tagträumt",
"Brainstormt",
"Braut Gedanken zusammen",
"Rührt im Gedankentopf",
"Lässt es köcheln",
"Dreht Neuronen",
"Wärmt die Denkerstube auf",
"Brütet Ideen aus",
// Mystical/fun
"Befragt das Orakel",
"Liest im Kaffeesatz",
"Beschwört Weisheit",
"Zaubert eine Antwort",
"Wirft neuronale Netze aus",
"Weissagt die Antwort",
// Quirky/silly
"Macht sein Ding",
"Zaubert eine Antwort herbei",
"Aktiviert Gehirnzellen",
"Biegt Neuronen",
"Wärmt die Transistoren auf",
"Dreht die Synapsen hoch",
"Kitzelt die Großhirnrinde",
"Weckt die Hamster",
"Befragt die Leere",
"Fragt die magische Kugel",
// Self-aware/meta
"Tut nur so, als würde es denken",
"Sieht beschäftigt aus",
"Zieht Zeit",
"Zählt Schafe",
"Dreht Däumchen",
"Ordnet die Gedanken",
"Sucht die richtigen Worte",
// Speed variations
"Denkt schnell",
"Blitzschnelles Denken",
"Tiefes Nachdenken",
"Hyperdenken",
// Action-oriented
"Zimmert eine Antwort",
"Webt Worte",
"Fügt Gedanken zusammen",
"Konstruiert Antworten",
"Formuliert Ideen",
"Dirigiert Neuronen",
"Choreografiert Bits",
// Whimsical
"Hat eine Eingebung",
"Verbindet die Punkte",
"Folgt dem roten Faden",
"Jagt Gedanken",
"Hütet Ideen",
"Entwirrt die Neuronen",
// Time-based
"Nimmt sich einen Moment",
"Atmet tief durch",
"Macht kurz Pause",
"Sammelt die Gedanken",
"Holt Luft",
// Just plain weird
"Piept und rechnet",
"Aktiviert den Gehirnmodus",
"Schaltet auf Schlaumodus",
"Lädt Gedanken herunter",
"Puffert Intelligenz",
"Lädt Genialität",
"Entfaltet Weisheit",
"Wühlt in der Ideenkiste",
"Sortiert die Gedanken",
"Wartet auf einen Geistesblitz",
"Quält das Gehirn damit",
"Kocht vor Ideen",
"Grübelt laut",
"Philosophiert über die Sache"
]
// MARK: - French
private static let frVerbs = [
// Classic thinking
"Réfléchit",
"Songe",
"Médite",
"Contemple",
"Spécule",
"Délibère",
"Rumine",
// Fancy/sophisticated
"Philosophise",
"Raisonne",
"Cogite",
"Analyse en profondeur",
"Aiguise ses neurones",
// Technical/AI themed
"Calcule",
"Traite",
"Analyse",
"Synthétise",
"Déduit",
"Compile des pensées",
"Fait tourner des algorithmes",
"Broie des données",
"Parse des neurones",
"Chiffre les possibilités",
// Creative/playful
"Rêvasse",
"Brainstorme",
"Mijote des idées",
"Remue la marmite à idées",
"Laisse mijoter",
"Fait tourner ses neurones",
"Chauffe la matière grise",
"Couve une idée",
// Mystical/fun
"Consulte l'oracle",
"Lit dans le marc de café",
"Invoque la sagesse",
"Conjure une réponse",
"Lance des filets neuronaux",
"Prédit la réponse",
// Quirky/silly
"Fait son truc",
"Fait apparaître une réponse par magie",
"Active ses neurones",
"Étire ses neurones",
"Chauffe les transistors",
"Emballe les synapses",
"Chatouille le cortex",
"Réveille les hamsters",
"Consulte le vide",
"Interroge la boule magique",
// Self-aware/meta
"Fait semblant de réfléchir",
"A l'air occupé",
"Fait durer le suspense",
"Compte les moutons",
"Se tourne les pouces",
"Met de l'ordre dans ses pensées",
"Cherche les mots justes",
// Speed variations
"Réfléchit vite",
"Pensée éclair",
"Réflexion profonde",
"Hyper-réflexion",
// Action-oriented
"Bricole une réponse",
"Tisse des mots",
"Assemble des idées",
"Construit une réponse",
"Formule des idées",
"Dirige ses neurones",
"Chorégraphie des bits",
// Whimsical
"A une révélation",
"Relie les points",
"Suit le fil",
"Traque une pensée",
"Rassemble ses idées",
"Démêle ses neurones",
// Time-based
"Prend un instant",
"Respire un grand coup",
"Fait une pause",
"Rassemble ses pensées",
"Reprend son souffle",
// Just plain weird
"Bip bip, ça calcule",
"Active le mode cerveau",
"Passe en mode intelligent",
"Télécharge des pensées",
"Met l'intelligence en cache",
"Charge du génie",
"Déploie sa sagesse",
"Fouille sa boîte à idées",
"Trie ses pensées",
"Attend un éclair de génie",
"Torture son cerveau",
"Bouillonne d'idées",
"Rumine à voix haute",
"Philosophise sur la question"
]
}
+1
View File
@@ -152,4 +152,5 @@ enum Log {
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp")
nonisolated static let cli = AppLogger(subsystem: subsystem, category: "cli")
}
+74 -15
View File
@@ -120,6 +120,13 @@ class ChatViewModel {
var messages: [Message] = []
var inputText: String = ""
var isGenerating: Bool = false
/// Live "what's happening right now" line shown under ProcessingIndicator's thinking verb
/// while a tool-calling loop is running e.g. "🔧 Calling: read_file". Replaced in place each
/// round rather than appending a new chat message, so a long multi-round tool chain doesn't
/// stack up a growing list of rows. Not persisted; nil whenever no tool round is in flight. The
/// full chain is still recorded, just collapsed into one expandable summary message once the
/// loop finishes see generateAIResponseWithTools's use of allToolCallDetails.
var currentToolActivity: String? = nil
var sessionStats = SessionStats()
var selectedModel: ModelInfo?
var currentProvider: Settings.Provider = .openrouter
@@ -1143,10 +1150,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
if let usage = response.usage {
messages[index].tokens = usage.completionTokens
if let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
}
}
}
@@ -1207,10 +1214,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
if let usage = totalTokens {
messages[index].tokens = usage.completionTokens
if let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
}
}
@@ -1567,6 +1574,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
let cost = usage.rawCostUSD
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
}
}
} catch {
@@ -1589,6 +1597,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
streamingTask = Task {
let startTime = Date()
var wasCancelled = false
// Accumulates ToolCallDetail entries across every round of this tool-calling loop
// collapsed into a single expandable summary message once the loop exits (success,
// cancellation, or error), instead of one persisted message per round.
var allToolCallDetails: [ToolCallDetail] = []
do {
// Include web_search tool when online mode is on (not needed for OpenRouter it handles search via :online suffix)
let tools = mcp.getToolSchemas(onlineMode: onlineMode && currentProvider != .openrouter)
@@ -1741,15 +1753,16 @@ Don't narrate future actions ("Let me...") - just use the tools.
break
}
// Show what tools the model is calling
// Show what tools the model is calling as a transient status line rather than
// a new chat message see currentToolActivity's doc comment.
let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
let toolMsgId = showSystemMessage("🔧 Calling: \(toolNames)")
currentToolActivity = String(localized: "🔧 Calling: \(toolNames)")
// Initialise detail entries with inputs (results fill in below)
// Initialise detail entries with inputs (results fill in below); appended to
// allToolCallDetails once this round finishes executing.
var toolDetails: [ToolCallDetail] = toolCalls.map { tc in
ToolCallDetail(name: tc.functionName, input: tc.arguments, result: nil)
}
updateToolCallMessage(id: toolMsgId, details: toolDetails)
let usingTextCalls = !textCalls.isEmpty
@@ -1800,9 +1813,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
resultJSON = "{\"error\": \"Failed to serialize result\"}"
}
// Update the detail entry with the result so the UI can show it
// Record the result so the collapsed summary message can show it once
// the whole tool-calling loop finishes.
toolDetails[i].result = resultJSON
updateToolCallMessage(id: toolMsgId, details: toolDetails)
if usingTextCalls {
// Inject results as a user message for text-call models
@@ -1822,6 +1835,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
apiMessages.append(["role": "user", "content": combined])
}
allToolCallDetails.append(contentsOf: toolDetails)
// If this was the last iteration, note it
if iteration == maxIterations - 1 {
hitIterationLimit = true // We're exiting with pending tool calls
@@ -1834,6 +1849,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
wasCancelled = true
}
flushToolCallSummary(allToolCallDetails)
// If we hit the iteration limit or the model returned no text at all, silently
// nudge a follow-up turn instead of showing a placeholder/blank bubble.
let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled
@@ -1843,13 +1860,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
if willAutoContinue && finalContent.isEmpty {
// Nothing worth showing yet still record usage/cost for this turn.
if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
sessionStats.addMessage(
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
cost: cost
)
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
}
} else {
let assistantMessage = Message(
@@ -1868,8 +1885,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Calculate cost
if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
messages[index].cost = cost
}
@@ -1878,6 +1894,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
outputTokens: usage.completionTokens,
cost: cost
)
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
}
}
@@ -1891,6 +1908,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
} catch {
let responseTime = Date().timeIntervalSince(startTime)
// Same collapse as the success path any tool rounds that completed before the
// error/cancellation are still worth keeping a record of.
flushToolCallSummary(allToolCallDetails)
// Check if this was a cancellation
let isCancellation = Task.isCancelled || wasCancelled || error is CancellationError
@@ -1935,7 +1956,17 @@ Don't narrate future actions ("Let me...") - just use the tools.
messages[idx].toolCalls = details
}
}
/// Clears the live tool-activity status line and, if any tool calls actually ran, collapses
/// them into a single persisted, expandable summary message used on every exit path of
/// generateAIResponseWithTools's loop (success, cancellation, or error).
private func flushToolCallSummary(_ details: [ToolCallDetail]) {
currentToolActivity = nil
guard !details.isEmpty else { return }
let summaryId = showSystemMessage("🔧 Used ^[\(details.count) tool call](inflect: true)")
updateToolCallMessage(id: summaryId, details: details)
}
// MARK: - Error Helpers
private func friendlyErrorMessage(from error: Error) -> String {
@@ -2574,6 +2605,34 @@ Don't narrate future actions ("Let me...") - just use the tools.
return inputCost + cacheReadCost + cacheWriteCost + outputCost
}
/// Resolves a response's cost, preferring the provider's actual billed amount
/// (`usage.rawCostUSD` e.g. OpenRouter's `usage.include` cost, needed for models priced
/// outside plain per-token rates like per-image generation) over token-based calculation.
/// Falls back to `nil` when neither the raw cost nor per-token pricing is available.
nonisolated static func resolveCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double? {
if let raw = usage.rawCostUSD { return raw }
guard pricing.prompt > 0 || pricing.completion > 0 else { return nil }
return calculateCost(usage: usage, pricing: pricing)
}
/// Records usage independent of whether this conversation ever gets explicitly saved (S)
/// conversation *text* is opt-in, but tokens/cost/model/provider are tracked for every
/// completed generation regardless, so the Analytics view reflects real usage either way.
private func logUsageEvent(modelId: String, promptTokens: Int?, completionTokens: Int?, cost: Double?) {
do {
try DatabaseService.shared.logUsageEvent(
provider: currentProvider.rawValue,
modelId: modelId,
promptTokens: promptTokens,
completionTokens: completionTokens,
cost: cost,
conversationId: currentConversationId
)
} catch {
Log.db.error("Failed to log usage event: \(error.localizedDescription)")
}
}
/// Summarize a chunk of messages into a concise summary
private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
guard let provider = providerRegistry.getProvider(for: currentProvider),
+30 -17
View File
@@ -53,7 +53,7 @@ struct ChatView: View {
// Processing indicator
if viewModel.isGenerating && viewModel.messages.last?.isStreaming != true {
ProcessingIndicator()
ProcessingIndicator(toolActivity: viewModel.currentToolActivity)
.padding(.horizontal)
}
@@ -156,30 +156,43 @@ struct ChatView: View {
}
struct ProcessingIndicator: View {
/// Current tool round's status (e.g. "🔧 Calling: read_file"), replaced in place each round
/// rather than the chat accumulating a new row per round see ChatViewModel.currentToolActivity.
let toolActivity: String?
@State private var animating = false
@State private var thinkingText = ThinkingVerbs.random()
var body: some View {
HStack(spacing: 8) {
Text(thinkingText)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.confabSecondary)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(thinkingText)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.confabSecondary)
HStack(spacing: 4) {
ForEach(0..<3) { index in
Circle()
.fill(Color.confabSecondary)
.frame(width: 6, height: 6)
.scaleEffect(animating ? 1.0 : 0.5)
.animation(
.easeInOut(duration: 0.6)
.repeatForever()
.delay(Double(index) * 0.2),
value: animating
)
HStack(spacing: 4) {
ForEach(0..<3) { index in
Circle()
.fill(Color.confabSecondary)
.frame(width: 6, height: 6)
.scaleEffect(animating ? 1.0 : 0.5)
.animation(
.easeInOut(duration: 0.6)
.repeatForever()
.delay(Double(index) * 0.2),
value: animating
)
}
}
}
if let toolActivity {
Text(toolActivity)
.font(.system(size: 12))
.foregroundColor(.confabSecondary.opacity(0.75))
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.15), value: toolActivity)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.confabSecondary.opacity(0.05))
+1 -1
View File
@@ -47,7 +47,7 @@ enum SyncState {
}
}
var tooltipText: String {
var tooltipText: LocalizedStringKey {
switch self {
case .disabled:
return "Auto-sync disabled"
+185 -61
View File
@@ -30,6 +30,7 @@ struct JarvisView: View {
@State private var isLoadingUsage = false
@State private var selectedAgent: JarvisAgent? = nil
@State private var editContext: AgentEditContext? = nil
@State private var selectedRun: JarvisAgentRun? = nil
@State private var errorMessage: String? = nil
@State private var actionInProgress: Set<String> = []
@@ -91,6 +92,9 @@ struct JarvisView: View {
await saveAgent(existing: ctx.agent, input: input)
})
}
.sheet(item: $selectedRun) { run in
JarvisRunDetailSheet(run: run)
}
}
// MARK: - Agents Tab
@@ -323,6 +327,8 @@ struct JarvisView: View {
} else {
List(agentRuns) { run in
RunHistoryRow(run: run)
.contentShape(Rectangle())
.onTapGesture { selectedRun = run }
}
.listStyle(.plain)
}
@@ -628,69 +634,41 @@ struct JarvisView: View {
private struct RunHistoryRow: View {
let run: JarvisAgentRun
@State private var expanded = false
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
statusIcon
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 6) {
Text(run.formattedStarted)
HStack(spacing: 8) {
statusIcon
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 6) {
Text(run.formattedStarted)
.font(.system(size: 12))
if let dur = run.formattedDuration {
Text("· \(dur)")
.font(.system(size: 12))
if let dur = run.formattedDuration {
Text("· \(dur)")
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
}
HStack(spacing: 8) {
if run.totalTokens > 0 {
Text("^[\(run.totalTokens) token](inflect: true)")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let cost = run.costUsd, cost > 0 {
Text(String(format: "$%.5f", cost))
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
.foregroundStyle(.secondary)
}
}
Spacer()
if run.output != nil || run.error != nil {
Button {
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
} label: {
Image(systemName: expanded ? "chevron.up" : "chevron.down")
HStack(spacing: 8) {
if run.totalTokens > 0 {
Text("^[\(run.totalTokens) token](inflect: true)")
.font(.caption2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
if let cost = run.costUsd, cost > 0 {
Text(String(format: "$%.5f", cost))
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
}
}
if expanded {
if let err = run.error {
Text(err)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.red)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.red.opacity(0.06))
.cornerRadius(6)
.textSelection(.enabled)
} else if let out = run.output {
Text(out)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.primary)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray.opacity(0.07))
.cornerRadius(6)
.lineLimit(20)
.textSelection(.enabled)
}
Spacer()
// Tapping the row (wired by the caller) opens JarvisRunDetailSheet with the full,
// untruncated output/error this used to be an inline chevron-expand capped at
// lineLimit(20), which isn't "complete" for long output.
if run.output != nil || run.error != nil {
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
@@ -698,29 +676,175 @@ private struct RunHistoryRow: View {
@ViewBuilder
private var statusIcon: some View {
switch run.status {
JarvisRunStatusIcon(status: run.status, size: 14)
}
}
/// Shared between RunHistoryRow and JarvisRunDetailSheet so the two views can't drift apart.
fileprivate struct JarvisRunStatusIcon: View {
let status: String
var size: CGFloat = 14
var body: some View {
switch status {
case "running":
ProgressView().scaleEffect(0.6).frame(width: 14, height: 14)
case "completed":
ProgressView().scaleEffect(size / 24).frame(width: size, height: size)
case "completed", "success":
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 14))
.font(.system(size: size))
.foregroundStyle(.green)
case "failed":
case "failed", "error":
Image(systemName: "xmark.circle.fill")
.font(.system(size: 14))
.font(.system(size: size))
.foregroundStyle(.red)
case "stopped":
Image(systemName: "stop.circle.fill")
.font(.system(size: 14))
.font(.system(size: size))
.foregroundStyle(.orange)
default:
Image(systemName: "circle")
.font(.system(size: 14))
.font(.system(size: size))
.foregroundStyle(.secondary)
}
}
}
// MARK: - Run Detail Sheet
/// Full, untruncated view of a single run opened by tapping a row in the run history list.
/// Output/error use an explicit Copy button rather than .textSelection(.enabled): a multi-line
/// selectable Text can grab real AppKit first-responder status, and Escape then gets consumed by
/// its own cancelOperation: handling instead of dismissing the sheet (silent beep, no log) see
/// the identical bug fixed in ModelInfoView.
struct JarvisRunDetailSheet: View {
let run: JarvisAgentRun
@Environment(\.dismiss) var dismiss
@State private var showOutputCopied = false
@State private var showErrorCopied = false
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Run Details")
.font(.system(size: 18, weight: .bold))
Spacer()
JarvisRunStatusIcon(status: run.status, size: 16)
Text(run.status.capitalized)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.padding(.leading, 8)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 12)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
detailRow("Started", run.formattedStarted)
if let dur = run.formattedDuration {
detailRow("Duration", dur)
}
if let trigger = run.triggerType {
detailRow("Trigger", trigger.capitalized)
}
if run.totalTokens > 0 {
detailRow("Tokens", "\(run.totalTokens.formatted()) (\((run.inputTokens ?? 0).formatted()) in / \((run.outputTokens ?? 0).formatted()) out)")
}
if let cost = run.costUsd, cost > 0 {
detailRow("Cost", String(format: "$%.5f", cost))
}
}
if let err = run.error {
Divider()
outputBlock(title: "Error", text: err, color: .red, showCopied: $showErrorCopied)
}
if let out = run.output {
Divider()
outputBlock(title: "Output", text: out, color: .primary, showCopied: $showOutputCopied)
}
if run.output == nil && run.error == nil {
Text("No output for this run.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
.padding(24)
}
}
.frame(minWidth: 600, idealWidth: 700, minHeight: 450, idealHeight: 620)
.onExitCommand { dismiss() }
}
private func detailRow(_ label: LocalizedStringKey, _ value: String) -> some View {
HStack {
Text(label)
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 90, alignment: .leading)
Text(value)
.font(.system(size: 13))
Spacer()
}
}
@ViewBuilder
private func outputBlock(title: LocalizedStringKey, text: String, color: Color, showCopied: Binding<Bool>) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text(title)
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
Spacer()
Button(action: { copy(text, showCopied: showCopied) }) {
HStack(spacing: 3) {
Image(systemName: showCopied.wrappedValue ? "checkmark" : "doc.on.doc")
.font(.system(size: 11))
if showCopied.wrappedValue {
Text("Copied!")
.font(.system(size: 11))
}
}
.foregroundColor(showCopied.wrappedValue ? .green : .secondary)
}
.buttonStyle(.plain)
}
Text(text)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(color)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(color.opacity(0.06))
.cornerRadius(6)
}
}
private func copy(_ text: String, showCopied: Binding<Bool>) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
withAnimation {
showCopied.wrappedValue = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
showCopied.wrappedValue = false
}
}
}
}
// MARK: - Agent Editor Sheet
struct JarvisAgentEditorSheet: View {
+56 -16
View File
@@ -28,7 +28,7 @@ struct ModelInfoView: View {
@Environment(\.dismiss) var dismiss
@Bindable private var settings = SettingsService.shared
@State private var isDescriptionExpanded = false
@State private var showDescriptionCopied = false
var body: some View {
VStack(spacing: 0) {
@@ -73,25 +73,42 @@ struct ModelInfoView: View {
infoRow("Released", releaseDate.formatted(date: .abbreviated, time: .omitted))
}
if let desc = model.description {
// Always shown in full, no truncate/expand toggle Text with a lineLimit
// nested inside this view's ScrollView doesn't reliably compute wrapping/
// truncation (a well-documented SwiftUI/AppKit quirk: without a fixedSize
// hint it hard-clips mid-word with no ellipsis; with one, sibling views in
// the same VStack like the former "More" button can silently fail to
// lay out). The modal itself already scrolls, so a long description just
// means more scrolling, which sidesteps the whole bug class.
VStack(alignment: .leading, spacing: 6) {
Text("Description")
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
HStack(spacing: 6) {
Text("Description")
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
Spacer()
// A Copy button instead of .textSelection(.enabled): clicking into
// a multi-line selectable Text hands it real AppKit first-responder
// status, and Escape then gets consumed by that text view's own
// cancelOperation: handling before it ever reaches this modal's
// onExitCommand the beep-instead-of-dismiss bug Rune reported.
// infoRow's single-line values keep .textSelection(.enabled); only
// this multi-line block reproduced the bug.
Button(action: copyDescription) {
HStack(spacing: 3) {
Image(systemName: showDescriptionCopied ? "checkmark" : "doc.on.doc")
.font(.system(size: 11))
if showDescriptionCopied {
Text("Copied!")
.font(.system(size: 11))
}
}
.foregroundColor(showDescriptionCopied ? .green : .secondary)
}
.buttonStyle(.plain)
}
Text(desc)
.font(.body)
.foregroundColor(.primary)
.lineLimit(isDescriptionExpanded ? nil : 4)
.textSelection(.enabled)
if desc.count > 250 {
Button(isDescriptionExpanded ? "Less" : "More…") {
withAnimation(.easeInOut(duration: 0.2)) {
isDescriptionExpanded.toggle()
}
}
.font(.callout)
.foregroundStyle(.blue)
.buttonStyle(.plain)
}
}
.padding(.leading, 4)
}
@@ -217,6 +234,29 @@ struct ModelInfoView: View {
.padding(.vertical, 12)
}
.frame(minWidth: 550, idealWidth: 650, minHeight: 550, idealHeight: 750)
// Nearly every value in this modal has .textSelection(.enabled) (infoRow's value text,
// the description). Once one of those has text-selection focus, Escape can get
// intercepted by AppKit's text-selection machinery instead of reaching the close
// button's .keyboardShortcut(.escape) no action is bound there, so it just beeps
// instead of dismissing. onExitCommand is macOS's dedicated hook for the "Escape/Cancel"
// user command and fires regardless of which child currently holds focus, so it's a more
// reliable place to handle this than a single button's keyboardShortcut alone.
.onExitCommand { dismiss() }
}
private func copyDescription() {
guard let desc = model.description else { return }
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(desc, forType: .string)
withAnimation {
showDescriptionCopied = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
showDescriptionCopied = false
}
}
}
// MARK: - Layout Helpers
+337 -32
View File
@@ -25,6 +25,13 @@ import SwiftUI
import UniformTypeIdentifiers
import FoundationModels
/// One editable row in the External MCP Servers "add" sheet's env var / custom header lists.
private struct MCPKeyValuePair: Identifiable {
let id = UUID()
var key: String = ""
var value: String = ""
}
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@Bindable private var settingsService = SettingsService.shared
@@ -77,8 +84,13 @@ struct SettingsView: View {
// External MCP Servers state
@State private var showAddExternalMCPServer = false
@State private var newMCPServerName = ""
@State private var newMCPServerTransportKind: MCPTransportKind = .stdio
@State private var newMCPServerCommand = ""
@State private var newMCPServerArgs = ""
@State private var newMCPServerEnvPairs: [MCPKeyValuePair] = []
@State private var newMCPServerURL = ""
@State private var newMCPServerBearerToken = ""
@State private var newMCPServerHeaderPairs: [MCPKeyValuePair] = []
@State private var newMCPServerTimeout: Double = 30
private var externalMCPManager = ExternalMCPManager.shared
@@ -113,6 +125,11 @@ struct SettingsView: View {
@State private var isTestingEmailConnection = false
@State private var emailConnectionTestResult: String?
// CLI server state
@State private var showCLIModelSelector = false
@State private var cliAvailableModels: [ModelInfo] = []
@State private var isLoadingCLIModels = false
private let labelWidth: CGFloat = 160
// Default system prompt - generic for all models
@@ -468,17 +485,28 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.labelsHidden()
.fixedSize()
}
// Always visible (not gated behind Search Provider == .google): this key is also
// used for Google embeddings in Semantic Search (Advanced tab), which has nothing
// to do with which web search provider is selected. Search Engine ID is web-search
// specific, so that one stays conditional.
rowDivider()
row("Google API Key") {
SecureField("", text: $googleKey)
.textFieldStyle(.roundedBorder)
.frame(width: 300)
.onAppear { googleKey = settingsService.googleAPIKey ?? "" }
.onChange(of: googleKey) {
settingsService.googleAPIKey = googleKey.isEmpty ? nil : googleKey
}
}
VStack(alignment: .leading, spacing: 2) {
Text("Used for Google web search (if selected above) and for Google embeddings in Settings → Advanced → Semantic Search.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
if settingsService.searchProvider == .google {
rowDivider()
row("Google API Key") {
SecureField("", text: $googleKey)
.textFieldStyle(.roundedBorder)
.frame(width: 300)
.onAppear { googleKey = settingsService.googleAPIKey ?? "" }
.onChange(of: googleKey) {
settingsService.googleAPIKey = googleKey.isEmpty ? nil : googleKey
}
}
rowDivider()
row("Search Engine ID") {
TextField("", text: $googleEngineID)
@@ -911,6 +939,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
Divider()
externalMCPSection
// MARK: CLI Access
Divider()
cliServerSection
// MARK: Personal Data
if !PersonalDataTools.isHiddenPendingAppleFix {
Divider()
@@ -1040,7 +1072,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
VStack(alignment: .leading, spacing: 2) {
Text(server.name)
.font(.system(size: 14))
Text(([server.command] + server.args).joined(separator: " "))
Text(server.transportKind == .http ? server.url : ([server.command] + server.args).joined(separator: " "))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -1075,8 +1107,13 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
Button {
newMCPServerName = ""
newMCPServerTransportKind = .stdio
newMCPServerCommand = ""
newMCPServerArgs = ""
newMCPServerEnvPairs = []
newMCPServerURL = ""
newMCPServerBearerToken = ""
newMCPServerHeaderPairs = []
newMCPServerTimeout = 30
showAddExternalMCPServer = true
} label: {
@@ -1095,6 +1132,169 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
// MARK: - CLI Access Section
@ViewBuilder
private var cliServerSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "terminal")
.font(.title2)
.foregroundStyle(.green)
Text("CLI Access")
.font(.system(size: 18, weight: .semibold))
}
Text("Expose a local socket so a shell command (like a zsh \"ai\" function) can get a one-shot text reply from a single fixed model, without opening the app window. Confab must be running.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.bottom, 4)
.onAppear {
Task { await loadCLIModels() }
}
.sheet(isPresented: $showCLIModelSelector) {
ModelSelectorView(
models: cliAvailableModels.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending },
selectedModel: cliAvailableModels.first(where: { $0.id == settingsService.cliServerModel })
) { selectedModel in
settingsService.cliServerModel = selectedModel.id
showCLIModelSelector = false
CLIServerService.shared.restart()
}
}
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Status")
formSection {
row("Enable CLI Access") {
Toggle("", isOn: $settingsService.cliServerEnabled)
.toggleStyle(.switch)
.onChange(of: settingsService.cliServerEnabled) {
CLIServerService.shared.restart()
}
}
}
}
if settingsService.cliServerEnabled {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Model")
formSection {
row("Provider") {
Picker("", selection: $settingsService.cliServerProvider) {
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
Text(provider.displayName).tag(provider.rawValue)
}
}
.labelsHidden()
.frame(width: 250)
.onChange(of: settingsService.cliServerProvider) {
Task { await loadCLIModels() }
CLIServerService.shared.restart()
}
}
rowDivider()
row("Model") {
if isLoadingCLIModels {
ProgressView().scaleEffect(0.7).frame(width: 250, alignment: .leading)
} else if cliAvailableModels.isEmpty {
Text("No models available")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.secondary)
.frame(width: 250, alignment: .leading)
} else {
Button(action: { showCLIModelSelector = true }) {
HStack {
Text(cliAvailableModels.first(where: { $0.id == settingsService.cliServerModel })?.name ?? "Select model...")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 10))
.foregroundColor(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.frame(width: 250)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
}
}
}
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Shell Function")
Text("Add this to your ~/.zshrc, then run `ai \"your prompt\"` in Terminal:")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text(Self.cliShellFunctionSnippet)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.primary)
.textSelection(.enabled)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 8))
Text("Requires jq (brew install jq).")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
}
}
}
// `noglob` (applied via the alias, not inside the function) stops zsh from trying to glob-expand
// an unquoted prompt containing `?`/`*`/`[...]` e.g. `ai Who are you?` before `_ai_impl` ever
// runs. A plain `ai() { ... }` function can't protect its own call site this way: filename
// generation happens during command-line parsing, before the shell has even decided this is a
// function call. Quoting the prompt is still the fully robust habit for other shell metacharacters
// (`;`, `|`, backticks, `$()`), but this covers the specific class of crash users are most likely
// to hit by accident (an unquoted question ending in "?").
// Deliberately one line, no backslash continuations: a `\` line-continuation is silently
// broken by a trailing space or a dropped backslash from copy/pasting out of a chat UI or
// browser each following line then gets parsed as its own bogus command ("command not
// found: -H", etc.). A long single line has no continuation character to mangle.
private static let cliShellFunctionSnippet = """
_ai_impl() {
curl -s --unix-socket "$HOME/Library/Application Support/oAI/cli.sock" -H "Content-Type: application/json" -d "$(jq -n --arg p "$*" '{prompt: $p}')" http://localhost/ | jq -r 'if .error then "Error: " + .error else .response end'
}
alias ai='noglob _ai_impl'
"""
private func loadCLIModels() async {
guard settingsService.cliServerEnabled else {
cliAvailableModels = []
return
}
let providerRawValue = settingsService.cliServerProvider
guard let providerType = Settings.Provider(rawValue: providerRawValue),
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
cliAvailableModels = []
return
}
isLoadingCLIModels = true
defer { isLoadingCLIModels = false }
do {
let models = try await provider.listModels()
cliAvailableModels = models
if !models.contains(where: { $0.id == settingsService.cliServerModel }) {
if let firstModel = models.first {
settingsService.cliServerModel = firstModel.id
}
}
} catch {
Log.ui.error("Failed to load CLI server models: \(error.localizedDescription)")
cliAvailableModels = []
}
}
@ViewBuilder
private var addExternalMCPServerSheet: some View {
VStack(alignment: .leading, spacing: 20) {
@@ -1109,19 +1309,47 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.frame(width: 240)
}
rowDivider()
row("Command") {
TextField("safaridriver", text: $newMCPServerCommand)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
row("Type") {
Picker("", selection: $newMCPServerTransportKind) {
Text("Command").tag(MCPTransportKind.stdio)
Text("Remote (HTTP)").tag(MCPTransportKind.http)
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(width: 240)
.help("Command: a local program Confab launches itself. Remote (HTTP): an already-running MCP server reachable by URL.")
}
rowDivider()
row("Arguments") {
TextField("--mcp", text: $newMCPServerArgs)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
.help("Space-separated arguments")
switch newMCPServerTransportKind {
case .stdio:
row("Command") {
TextField("safaridriver", text: $newMCPServerCommand)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
}
rowDivider()
row("Arguments") {
TextField("--mcp", text: $newMCPServerArgs)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
.help("Space-separated arguments")
}
case .http:
row("Server URL") {
TextField("http://127.0.0.1:27123/mcp/", text: $newMCPServerURL)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
}
rowDivider()
row("Bearer Token") {
SecureField("Optional", text: $newMCPServerBearerToken)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
}
}
rowDivider()
row("Timeout") {
@@ -1136,6 +1364,15 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
formSection {
switch newMCPServerTransportKind {
case .stdio:
mcpKeyValueEditor(title: "Environment Variables", pairs: $newMCPServerEnvPairs)
case .http:
mcpKeyValueEditor(title: "Extra Headers", pairs: $newMCPServerHeaderPairs)
}
}
if !newMCPServerName.isEmpty {
let slug = ExternalMCPServer.makeSlug(from: newMCPServerName)
HStack(spacing: 6) {
@@ -1157,25 +1394,93 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
Button("Cancel") { showAddExternalMCPServer = false }
Spacer()
Button("Add") {
let args = ExternalMCPServer.parseArguments(newMCPServerArgs)
let server = ExternalMCPServer(
name: newMCPServerName,
command: newMCPServerCommand,
args: args,
timeout: newMCPServerTimeout
)
let server: ExternalMCPServer
switch newMCPServerTransportKind {
case .stdio:
server = ExternalMCPServer(
name: newMCPServerName,
transportKind: .stdio,
command: newMCPServerCommand,
args: ExternalMCPServer.parseArguments(newMCPServerArgs),
env: mcpDictionary(from: newMCPServerEnvPairs),
timeout: newMCPServerTimeout
)
case .http:
server = ExternalMCPServer(
name: newMCPServerName,
transportKind: .http,
url: newMCPServerURL,
bearerToken: newMCPServerBearerToken,
headers: mcpDictionary(from: newMCPServerHeaderPairs),
timeout: newMCPServerTimeout
)
}
settingsService.addExternalMCPServer(server)
showAddExternalMCPServer = false
}
.buttonStyle(.borderedProminent)
.disabled(newMCPServerName.isEmpty || newMCPServerCommand.isEmpty ||
ExternalMCPServer.reservedSlugs.contains(ExternalMCPServer.makeSlug(from: newMCPServerName)))
.disabled(isAddExternalMCPServerDisabled)
}
}
.padding(24)
.frame(minWidth: 460, minHeight: 320)
}
private var isAddExternalMCPServerDisabled: Bool {
if newMCPServerName.isEmpty { return true }
if ExternalMCPServer.reservedSlugs.contains(ExternalMCPServer.makeSlug(from: newMCPServerName)) { return true }
switch newMCPServerTransportKind {
case .stdio: return newMCPServerCommand.isEmpty
case .http: return newMCPServerURL.isEmpty
}
}
private func mcpDictionary(from pairs: [MCPKeyValuePair]) -> [String: String] {
Dictionary(uniqueKeysWithValues: pairs.filter { !$0.key.isEmpty }.map { ($0.key, $0.value) })
}
@ViewBuilder
private func mcpKeyValueEditor(title: LocalizedStringKey, pairs: Binding<[MCPKeyValuePair]>) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
Button {
pairs.wrappedValue.append(MCPKeyValuePair())
} label: {
Image(systemName: "plus.circle.fill")
}
.buttonStyle(.plain)
}
ForEach(pairs) { $pair in
HStack(spacing: 6) {
TextField("Key", text: $pair.key)
.textFieldStyle(.roundedBorder)
.font(.system(size: 12, design: .monospaced))
SecureField("Value", text: $pair.value)
.textFieldStyle(.roundedBorder)
.font(.system(size: 12, design: .monospaced))
Button {
pairs.wrappedValue.removeAll { $0.id == pair.id }
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
}
}
if pairs.wrappedValue.isEmpty {
Text("None configured")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
private func mcpStatusColor(_ state: MCPClientState?) -> Color {
switch state {
case .ready: return .green
@@ -1500,7 +1805,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
} else {
Text("⚠️ No embedding providers available. Configure an API key for OpenAI, OpenRouter, or Google in the General tab.")
Text("⚠️ No embedding providers available. Configure an API key for OpenAI, OpenRouter, or Google (under General → Web Search) in the General tab.")
.font(.system(size: 13))
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
+18 -5
View File
@@ -38,16 +38,26 @@ struct StatsView: View {
@State private var overallStats = UsageStats()
@State private var modelStats: [ModelUsageStat] = []
@State private var conversationStats: [ConversationUsageStat] = []
@State private var showAnalytics = false
var body: some View {
NavigationStack {
VStack(spacing: 0) {
Picker("", selection: $selectedTab) {
Text("Session").tag(StatsTab.session)
Text("All-Time").tag(StatsTab.allTime)
HStack(spacing: 8) {
Picker("", selection: $selectedTab) {
Text("Session").tag(StatsTab.session)
Text("All-Time").tag(StatsTab.allTime)
}
.pickerStyle(.segmented)
.labelsHidden()
Button {
showAnalytics = true
} label: {
Image(systemName: "chart.bar.xaxis")
}
.help("View detailed usage analytics")
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 4)
@@ -74,6 +84,9 @@ struct StatsView: View {
.task {
loadAllTimeStats()
}
.sheet(isPresented: $showAnalytics) {
UsageAnalyticsView()
}
}
private var sessionList: some View {
+418
View File
@@ -0,0 +1,418 @@
//
// UsageAnalyticsView.swift
// Confab
//
// Usage analytics: tokens / questions / money, over time and by model
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use including selling Confab or any part of
// it, standalone or bundled into another product or service
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
import SwiftUI
import Charts
private enum AnalyticsMetric: String, CaseIterable, Identifiable {
case tokens
case questions
case cost
var id: String { rawValue }
var icon: String {
switch self {
case .tokens: return "number"
case .questions: return "bubble.left.and.bubble.right"
case .cost: return "dollarsign.circle"
}
}
var color: Color {
switch self {
case .tokens: return .confabAccent
case .questions: return .confabSuccess
case .cost: return .confabWarning
}
}
}
private enum AnalyticsChartMode: String, CaseIterable, Identifiable {
case overTime
case byModel
case byProvider
var id: String { rawValue }
}
struct UsageAnalyticsView: View {
@Environment(\.dismiss) var dismiss
@State private var timeframe: AnalyticsTimeframe = .last7Days
@State private var metric: AnalyticsMetric = .tokens
@State private var chartMode: AnalyticsChartMode = .overTime
@State private var overallStats = UsageStats()
@State private var modelStats: [ModelUsageStat] = []
@State private var providerStats: [ProviderUsageStat] = []
@State private var dailyStats: [DailyUsageStat] = []
var body: some View {
NavigationStack {
VStack(spacing: 0) {
timeframePicker
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
summaryTiles
VStack(alignment: .leading, spacing: 12) {
chartModePicker
chartSection
}
if chartMode == .byModel && !modelStats.isEmpty {
modelBreakdownList
}
if chartMode == .byProvider && !providerStats.isEmpty {
providerBreakdownList
}
}
.padding(20)
}
}
.navigationTitle("Analytics")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
.frame(minWidth: 700, idealWidth: 780, minHeight: 620, idealHeight: 680)
}
.task(id: timeframe) {
loadStats()
}
}
// MARK: - Timeframe
private var timeframePicker: some View {
Picker("", selection: $timeframe) {
Text("Today").tag(AnalyticsTimeframe.today)
Text("7 Days").tag(AnalyticsTimeframe.last7Days)
Text("Week").tag(AnalyticsTimeframe.week)
Text("Month").tag(AnalyticsTimeframe.month)
Text("Year").tag(AnalyticsTimeframe.year)
Text("Total").tag(AnalyticsTimeframe.total)
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 12)
}
// MARK: - Summary Tiles
private var summaryTiles: some View {
HStack(spacing: 12) {
MetricTile(
title: "Tokens",
value: overallStats.totalTokensDisplay,
icon: AnalyticsMetric.tokens.icon,
color: AnalyticsMetric.tokens.color,
isSelected: metric == .tokens
) { metric = .tokens }
MetricTile(
title: "Questions",
value: "\(overallStats.totalQuestions)",
icon: AnalyticsMetric.questions.icon,
color: AnalyticsMetric.questions.color,
isSelected: metric == .questions
) { metric = .questions }
MetricTile(
title: "Money Used",
value: overallStats.totalCostDisplay,
icon: AnalyticsMetric.cost.icon,
color: AnalyticsMetric.cost.color,
isSelected: metric == .cost
) { metric = .cost }
}
}
// MARK: - Chart
private var chartModePicker: some View {
Picker("", selection: $chartMode) {
Text("Over Time").tag(AnalyticsChartMode.overTime)
Text("By Model").tag(AnalyticsChartMode.byModel)
Text("By Provider").tag(AnalyticsChartMode.byProvider)
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(maxWidth: 400)
}
@ViewBuilder
private var chartSection: some View {
switch chartMode {
case .overTime:
timeSeriesChart
case .byModel:
byModelChart
case .byProvider:
byProviderChart
}
}
private var timeSeriesChart: some View {
Group {
if dailyStats.isEmpty {
emptyChartPlaceholder
} else {
Chart(dailyStats) { stat in
BarMark(
x: .value("Day", stat.day, unit: .day),
y: .value("Value", dailyValue(for: stat))
)
.foregroundStyle(metric.color)
}
.frame(height: 220)
}
}
}
private var byModelChart: some View {
Group {
if modelStats.isEmpty {
emptyChartPlaceholder
} else {
Chart(modelStats) { stat in
SectorMark(
angle: .value("Value", modelValue(for: stat)),
innerRadius: .ratio(0.55),
angularInset: 1.5
)
.foregroundStyle(by: .value("Model", stat.modelId))
.cornerRadius(4)
}
.frame(height: 240)
.chartLegend(position: .bottom, alignment: .center, spacing: 8)
}
}
}
private var byProviderChart: some View {
Group {
if providerStats.isEmpty {
emptyChartPlaceholder
} else {
Chart(providerStats) { stat in
SectorMark(
angle: .value("Value", providerValue(for: stat)),
innerRadius: .ratio(0.55),
angularInset: 1.5
)
.foregroundStyle(by: .value("Provider", providerDisplayName(stat.provider)))
.cornerRadius(4)
}
.frame(height: 240)
.chartLegend(position: .bottom, alignment: .center, spacing: 8)
}
}
}
private var emptyChartPlaceholder: some View {
VStack(spacing: 8) {
Image(systemName: "chart.bar.xaxis")
.font(.system(size: 32))
.foregroundColor(.secondary)
Text("No usage data for this timeframe")
.font(.callout)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.frame(height: 220)
}
// MARK: - By Model List
private var modelBreakdownList: some View {
VStack(alignment: .leading, spacing: 4) {
Text("By Model")
.font(.headline)
.padding(.bottom, 4)
ForEach(modelStats) { stat in
HStack {
Text(stat.modelId)
.font(.body)
.lineLimit(1)
Spacer()
Text(modelValueDisplay(for: stat))
.font(.body.monospacedDigit())
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
if stat.id != modelStats.last?.id {
Divider()
}
}
}
.padding(16)
.background(Color.confabSurface.opacity(0.5))
.cornerRadius(10)
}
// MARK: - By Provider List
private var providerBreakdownList: some View {
VStack(alignment: .leading, spacing: 4) {
Text("By Provider")
.font(.headline)
.padding(.bottom, 4)
ForEach(providerStats) { stat in
HStack {
Text(providerDisplayName(stat.provider))
.font(.body)
.lineLimit(1)
Spacer()
Text(providerValueDisplay(for: stat))
.font(.body.monospacedDigit())
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
if stat.id != providerStats.last?.id {
Divider()
}
}
}
.padding(16)
.background(Color.confabSurface.opacity(0.5))
.cornerRadius(10)
}
private func providerDisplayName(_ rawProvider: String) -> String {
Settings.Provider(rawValue: rawProvider)?.displayName ?? rawProvider
}
// MARK: - Value helpers
private func dailyValue(for stat: DailyUsageStat) -> Double {
switch metric {
case .tokens: return Double(stat.totalTokens)
case .questions: return Double(stat.questionCount)
case .cost: return stat.totalCost
}
}
private func modelValue(for stat: ModelUsageStat) -> Double {
switch metric {
case .tokens: return Double(stat.totalTokens)
case .questions: return Double(stat.questionCount)
case .cost: return stat.totalCost
}
}
private func modelValueDisplay(for stat: ModelUsageStat) -> String {
switch metric {
case .tokens: return stat.totalTokensDisplay
case .questions: return "\(stat.questionCount)"
case .cost: return stat.totalCostDisplay
}
}
private func providerValue(for stat: ProviderUsageStat) -> Double {
switch metric {
case .tokens: return Double(stat.totalTokens)
case .questions: return Double(stat.questionCount)
case .cost: return stat.totalCost
}
}
private func providerValueDisplay(for stat: ProviderUsageStat) -> String {
switch metric {
case .tokens: return stat.totalTokensDisplay
case .questions: return "\(stat.questionCount)"
case .cost: return stat.totalCostDisplay
}
}
// MARK: - Data Loading
/// Sourced from `usage_events`, not `messages` reflects usage for every completed
/// generation regardless of whether the conversation it belongs to was ever saved.
private func loadStats() {
let range = timeframe.dateRange()
overallStats = (try? DatabaseService.shared.getUsageEventTotals(from: range.start, to: range.end)) ?? UsageStats()
modelStats = (try? DatabaseService.shared.getUsageEventsByModel(from: range.start, to: range.end)) ?? []
providerStats = (try? DatabaseService.shared.getUsageEventsByProvider(from: range.start, to: range.end)) ?? []
let dailyFrom = range.start ?? overallStats.firstMessageDate ?? range.end
dailyStats = (try? DatabaseService.shared.getDailyUsageEvents(from: dailyFrom, to: range.end)) ?? []
}
}
// MARK: - Metric Tile
private struct MetricTile: View {
let title: LocalizedStringKey
let value: String
let icon: String
let color: Color
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: icon)
.foregroundColor(color)
Spacer()
}
Text(value)
.font(.title2.monospacedDigit())
.fontWeight(.bold)
.foregroundColor(.primary)
.lineLimit(1)
.minimumScaleFactor(0.6)
Text(title)
.font(.caption)
.foregroundColor(.secondary)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? color.opacity(0.15) : Color.confabSurface.opacity(0.5))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(isSelected ? color : Color.clear, lineWidth: 1.5)
)
.cornerRadius(10)
}
.buttonStyle(.plain)
}
}
#Preview {
UsageAnalyticsView()
}
+1 -49
View File
@@ -3,7 +3,7 @@
"strings" : {
"CFBundleName" : {
"comment" : "Bundle name",
"extractionState" : "extracted_with_value",
"extractionState" : "stale",
"localizations" : {
"da" : {
"stringUnit" : {
@@ -36,54 +36,6 @@
}
}
}
},
"NSCalendarsFullAccessUsageDescription" : {
"comment" : "Privacy - Calendars Full Access Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Confab can read and create calendar events when you ask it to, if you enable Calendar access in Settings."
}
}
}
},
"NSContactsUsageDescription" : {
"comment" : "Privacy - Contacts Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Confab can search your contacts when you ask it to, if you enable Contacts access in Settings."
}
}
}
},
"NSLocationWhenInUseUsageDescription" : {
"comment" : "Privacy - Location When In Use Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Confab can use your current location to answer questions, if you enable Location & Maps access in Settings."
}
}
}
},
"NSRemindersFullAccessUsageDescription" : {
"comment" : "Privacy - Reminders Full Access Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Confab can read and create reminders when you ask it to, if you enable Reminders access in Settings."
}
}
}
}
},
"version" : "1.1"
+15
View File
@@ -73,6 +73,9 @@ struct oAIApp: App {
// Start email handler on app launch
EmailHandlerService.shared.start()
// Start the local CLI server (Settings > Advanced > CLI Access) no-op if disabled
CLIServerService.shared.start()
// Start external MCP servers
Task { @MainActor in ExternalMCPManager.shared.startAll() }
@@ -88,6 +91,17 @@ struct oAIApp: App {
// Check for updates in the background
UpdateCheckService.shared.checkForUpdates()
// Periodic heartbeat: on its own, a quiet Confab.log is ambiguous it could mean the
// user just wasn't chatting, or that the app silently froze (confirmed possible on
// 2026-08-14: a stuck process left the CLI socket open per `lsof` yet refused every
// connection, with the log not written to at all for the rest of that session no
// crash, no error, nothing to distinguish "idle" from "wedged" after the fact). A
// regular, otherwise-meaningless log line turns that ambiguity into a plain read: if the
// *next* freeze happens, the gap since the last heartbeat pins down roughly when.
Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { _ in
Log.general.info("heartbeat")
}
}
var body: some Scene {
@@ -106,6 +120,7 @@ struct oAIApp: App {
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
CLIServerService.shared.stop()
}
#endif
}
+107
View File
@@ -0,0 +1,107 @@
//
// CLIServerServiceTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("CLIServerService pure HTTP helpers")
struct CLIServerServiceTests {
// MARK: - parseRequestBody
@Test("Returns the body once headers and full Content-Length body have arrived")
func parsesCompleteRequest() {
let body = "{\"prompt\":\"hi\"}"
let raw = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
}
@Test("Returns nil when the header block hasn't fully arrived yet")
func returnsNilForIncompleteHeaders() {
let raw = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5"
#expect(CLIServerService.parseRequestBody(from: Data(raw.utf8)) == nil)
}
@Test("Returns nil when the body hasn't fully arrived yet")
func returnsNilForIncompleteBody() {
let raw = "POST / HTTP/1.1\r\nContent-Length: 20\r\n\r\n{\"prompt\":\"hi\"}"
#expect(CLIServerService.parseRequestBody(from: Data(raw.utf8)) == nil)
}
@Test("Content-Length header name match is case-insensitive")
func headerNameIsCaseInsensitive() {
let body = "abc"
let raw = "POST / HTTP/1.1\r\ncontent-length: 3\r\n\r\n\(body)"
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
}
@Test("Missing Content-Length is treated as a zero-length body")
func missingContentLengthIsEmptyBody() {
let raw = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
#expect(result?.isEmpty == true)
}
@Test("Extra trailing bytes beyond Content-Length don't prevent extraction (pipelined data ignored)")
func extraTrailingBytesStillExtractsBody() {
let body = "abc"
let raw = "POST / HTTP/1.1\r\nContent-Length: 3\r\n\r\n\(body)EXTRA"
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
}
// MARK: - Response encoding
@Test("successResponse produces a 200 with the response field set")
func successResponseShape() throws {
let data = CLIServerService.successResponse("hello world")
let text = String(data: data, encoding: .utf8)!
#expect(text.hasPrefix("HTTP/1.1 200 OK\r\n"))
#expect(text.contains("Content-Type: application/json"))
let bodyStart = text.range(of: "\r\n\r\n")!.upperBound
let bodyJSON = Data(text[bodyStart...].utf8)
let decoded = try JSONDecoder().decode(CLIServerService.AskResponseBody.self, from: bodyJSON)
#expect(decoded.response == "hello world")
#expect(decoded.error == nil)
}
@Test("errorResponse defaults to 400 Bad Request")
func errorResponseDefaultStatus() throws {
let data = CLIServerService.errorResponse("bad input")
let text = String(data: data, encoding: .utf8)!
#expect(text.hasPrefix("HTTP/1.1 400 Bad Request\r\n"))
let bodyStart = text.range(of: "\r\n\r\n")!.upperBound
let bodyJSON = Data(text[bodyStart...].utf8)
let decoded = try JSONDecoder().decode(CLIServerService.AskResponseBody.self, from: bodyJSON)
#expect(decoded.error == "bad input")
#expect(decoded.response == nil)
}
@Test("errorResponse supports a custom status code")
func errorResponseCustomStatus() {
let data = CLIServerService.errorResponse("provider failed", statusCode: 500)
let text = String(data: data, encoding: .utf8)!
#expect(text.hasPrefix("HTTP/1.1 500 Internal Server Error\r\n"))
}
@Test("Content-Length in the response header matches the actual JSON body byte count")
func responseContentLengthMatchesBody() {
let data = CLIServerService.successResponse("hello")
let text = String(data: data, encoding: .utf8)!
let headerEnd = text.range(of: "\r\n\r\n")!.upperBound
let bodyByteCount = Data(text[headerEnd...].utf8).count
let lengthLine = text.split(separator: "\r\n").first { $0.hasPrefix("Content-Length:") }!
let declaredLength = Int(lengthLine.split(separator: " ")[1])!
#expect(declaredLength == bodyByteCount)
}
}
@@ -82,6 +82,36 @@ struct ChatViewModelPureLogicTests {
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.0)
}
// MARK: - resolveCost
@Test("Raw provider-billed cost wins over token-based pricing when present")
func resolveCostPrefersRawCostUSD() {
let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000, rawCostUSD: 0.19)
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
#expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == 0.19)
}
@Test("Raw cost of zero is trusted, not treated as missing")
func resolveCostTrustsZeroRawCost() {
let usage = ChatResponse.Usage(promptTokens: 0, completionTokens: 0, totalTokens: 0, rawCostUSD: 0.0)
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
#expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == 0.0)
}
@Test("Falls back to token-based pricing when no raw cost is reported")
func resolveCostFallsBackToCalculation() {
let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000)
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
#expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == 18.0)
}
@Test("Returns nil when neither raw cost nor per-token pricing is available")
func resolveCostNilWhenNoDataAvailable() {
let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000)
let pricing = ModelInfo.Pricing(prompt: 0, completion: 0)
#expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == nil)
}
// MARK: - draftFingerprint
@Test("Identical message content produces the same fingerprint")
+163
View File
@@ -25,6 +25,17 @@ struct DatabaseServiceMigrationTests {
}
}
@Test("v13 adds the usage_events table with the expected columns")
func v13AddsUsageEventsTable() {
let db = DatabaseService.makeInMemory()
#expect(db.tableExists("usage_events"))
let columns = Set(db.columnNames(in: "usage_events"))
let expected: Set<String> = [
"id", "timestamp", "provider", "modelId", "promptTokens", "completionTokens", "cost", "conversationId",
]
#expect(expected.isSubset(of: columns))
}
@Test("v4 adds modelId to messages and primaryModel to conversations")
func v4AddsModelColumns() {
let db = DatabaseService.makeInMemory()
@@ -192,6 +203,84 @@ struct DatabaseServiceUsageStatsTests {
#expect(byModel.first?.modelId == "claude-sonnet")
}
@Test("Overall stats respect an optional date range and count user questions")
func overallStatsRespectsDateRangeAndQuestions() throws {
let db = DatabaseService.makeInMemory()
let now = Date()
let old = now.addingTimeInterval(-10 * 24 * 3600)
_ = try db.saveConversation(name: "Old Chat", messages: [
Message(role: .user, content: "old question", tokens: 10, timestamp: old),
Message(role: .assistant, content: "old answer", tokens: 20, cost: 0.01, timestamp: old),
])
_ = try db.saveConversation(name: "Recent Chat", messages: [
Message(role: .user, content: "recent question", tokens: 5, timestamp: now),
Message(role: .assistant, content: "recent answer", tokens: 15, cost: 0.02, timestamp: now),
])
let rangeStats = try db.getOverallUsageStats(
from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600)
)
#expect(rangeStats.totalMessages == 2)
#expect(rangeStats.totalQuestions == 1)
#expect(rangeStats.totalTokens == 20)
let allStats = try db.getOverallUsageStats()
#expect(allStats.totalMessages == 4)
#expect(allStats.totalQuestions == 2)
}
@Test("Usage by model respects an optional date range and reports question counts")
func usageByModelRespectsDateRangeAndQuestions() throws {
let db = DatabaseService.makeInMemory()
let now = Date()
let old = now.addingTimeInterval(-10 * 24 * 3600)
_ = try db.saveConversation(name: "Old Chat", messages: [
Message(role: .user, content: "hi", tokens: 5, timestamp: old, modelId: "claude-sonnet"),
Message(role: .assistant, content: "hello", tokens: 10, cost: 0.01, timestamp: old, modelId: "claude-sonnet"),
])
_ = try db.saveConversation(name: "Recent Chat", messages: [
Message(role: .user, content: "hey", tokens: 5, timestamp: now, modelId: "gpt-4"),
Message(role: .assistant, content: "hi", tokens: 15, cost: 0.02, timestamp: now, modelId: "gpt-4"),
])
let recent = try db.getUsageByModel(
from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600)
)
#expect(recent.count == 1)
#expect(recent.first?.modelId == "gpt-4")
#expect(recent.first?.questionCount == 1)
}
@Test("Daily usage buckets messages by calendar day (GMT, matching stored timestamps)")
func dailyUsageBucketsByDay() throws {
let db = DatabaseService.makeInMemory()
var gmtCalendar = Calendar(identifier: .gregorian)
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
let day1 = gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 1, hour: 10))!
let day2 = gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 2, hour: 10))!
_ = try db.saveConversation(name: "Chat", messages: [
Message(role: .user, content: "q1", tokens: 5, timestamp: day1),
Message(role: .assistant, content: "a1", tokens: 10, cost: 0.01, timestamp: day1),
Message(role: .user, content: "q2", tokens: 5, timestamp: day2),
Message(role: .assistant, content: "a2", tokens: 20, cost: 0.02, timestamp: day2),
])
let daily = try db.getDailyUsage(
from: gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 1))!,
to: gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 3))!
)
#expect(daily.count == 2)
#expect(daily[0].totalTokens == 15)
#expect(daily[0].questionCount == 1)
#expect(daily[0].hasCostData == true)
#expect(daily[1].totalTokens == 25)
#expect(daily[1].questionCount == 1)
}
@Test("Usage by conversation joins conversation names and sorts by cost descending")
func usageByConversationSortsByCost() throws {
let db = DatabaseService.makeInMemory()
@@ -221,6 +310,80 @@ struct DatabaseServiceUsageStatsTests {
}
}
@Suite("DatabaseService usage events, against a throwaway in-memory queue")
struct DatabaseServiceUsageEventsTests {
@Test("Logged usage events are readable regardless of any saved conversation")
func logUsageEventPersistsWithNoConversation() throws {
let db = DatabaseService.makeInMemory()
try db.logUsageEvent(
provider: "openrouter", modelId: "gpt-5", promptTokens: 100, completionTokens: 50,
cost: 0.02, conversationId: nil
)
let totals = try db.getUsageEventTotals()
#expect(totals.totalQuestions == 1)
#expect(totals.totalTokens == 150)
#expect(totals.hasCostData == true)
#expect(abs(totals.totalCost - 0.02) < 0.0001)
}
@Test("Usage events respect an optional date range")
func usageEventTotalsRespectsDateRange() throws {
let db = DatabaseService.makeInMemory()
let now = Date()
let old = now.addingTimeInterval(-10 * 24 * 3600)
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
// Directly insert an old-dated row via the private record path isn't exposed, so approximate
// "old" coverage by asserting the fresh row is excluded when the range is set entirely in the past.
let rangeExcludingNow = try db.getUsageEventTotals(from: old, to: old.addingTimeInterval(3600))
#expect(rangeExcludingNow.totalQuestions == 0)
let rangeIncludingNow = try db.getUsageEventTotals(from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600))
#expect(rangeIncludingNow.totalQuestions == 1)
}
@Test("Usage events group by model and by provider independently")
func usageEventsGroupByModelAndProvider() throws {
let db = DatabaseService.makeInMemory()
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
try db.logUsageEvent(provider: "openrouter", modelId: "claude-sonnet", promptTokens: 5, completionTokens: 5, cost: 0.02, conversationId: nil)
try db.logUsageEvent(provider: "anthropic", modelId: "claude-sonnet", promptTokens: 20, completionTokens: 20, cost: 0.03, conversationId: nil)
let byModel = try db.getUsageEventsByModel()
#expect(byModel.count == 2)
let sonnet = byModel.first { $0.modelId == "claude-sonnet" }
#expect(sonnet?.questionCount == 2)
#expect(sonnet?.totalTokens == 50)
let byProvider = try db.getUsageEventsByProvider()
#expect(byProvider.count == 2)
let openrouter = byProvider.first { $0.provider == "openrouter" }
#expect(openrouter?.questionCount == 2)
#expect(openrouter?.totalTokens == 30)
let anthropic = byProvider.first { $0.provider == "anthropic" }
#expect(anthropic?.questionCount == 1)
#expect(anthropic?.totalTokens == 40)
}
@Test("Daily usage events bucket by calendar day (GMT, matching stored timestamps)")
func dailyUsageEventsBucketByDay() throws {
let db = DatabaseService.makeInMemory()
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
var gmtCalendar = Calendar(identifier: .gregorian)
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
let farPast = gmtCalendar.date(from: DateComponents(year: 2020, month: 1, day: 1))!
let farFuture = gmtCalendar.date(from: DateComponents(year: 2030, month: 1, day: 1))!
let daily = try db.getDailyUsageEvents(from: farPast, to: farFuture)
#expect(daily.count == 1)
#expect(daily.first?.totalTokens == 20)
#expect(daily.first?.questionCount == 1)
}
}
@Suite("DatabaseService folders, against a throwaway in-memory queue")
struct DatabaseServiceFolderTests {
@@ -0,0 +1,65 @@
//
// ExternalMCPManagerConversionTests.swift
// ConfabTests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("ExternalMCPManager.convertInputSchema")
struct ExternalMCPManagerConversionTests {
/// Regression test for a real crash: Obsidian's Local REST API plugin's MCP tool schemas
/// include at least one property with no "type" key at all valid JSON Schema (e.g. an
/// enum-only or composed property) which a `prop.type!` force-unwrap used to crash on the
/// moment a live HTTP MCP server's tools/list response reached this code.
@Test("A property with no type at all defaults to string instead of crashing")
func propertyWithNoTypeDefaultsToString() {
let prop = MCPPropertySchema(type: nil, description: "no explicit type", enum: nil, items: nil)
let schema = MCPInputSchema(type: "object", properties: ["mode": prop], required: nil)
let parameters = ExternalMCPManager.convertInputSchema(schema)
#expect(parameters.properties["mode"]?.type == "string")
#expect(parameters.properties["mode"]?.description == "no explicit type")
}
@Test("integer is normalized to number")
func integerNormalizesToNumber() {
let prop = MCPPropertySchema(type: "integer", description: nil, enum: nil, items: nil)
let schema = MCPInputSchema(type: "object", properties: ["count": prop], required: nil)
let parameters = ExternalMCPManager.convertInputSchema(schema)
#expect(parameters.properties["count"]?.type == "number")
}
@Test("An unrecognized type string falls back to string")
func unrecognizedTypeFallsBackToString() {
let prop = MCPPropertySchema(type: "something-unusual", description: nil, enum: nil, items: nil)
let schema = MCPInputSchema(type: "object", properties: ["weird": prop], required: nil)
let parameters = ExternalMCPManager.convertInputSchema(schema)
#expect(parameters.properties["weird"]?.type == "string")
}
@Test("Recognized types (string/number/boolean/array/object) pass through unchanged")
func recognizedTypesPassThrough() {
let types = ["string", "number", "boolean", "array", "object"]
var properties: [String: MCPPropertySchema] = [:]
for t in types {
properties[t] = MCPPropertySchema(type: t, description: nil, enum: nil, items: nil)
}
let schema = MCPInputSchema(type: "object", properties: properties, required: nil)
let parameters = ExternalMCPManager.convertInputSchema(schema)
for t in types {
#expect(parameters.properties[t]?.type == t)
}
}
}
+105
View File
@@ -0,0 +1,105 @@
//
// ExternalMCPModelsTests.swift
// ConfabTests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("ExternalMCPServer Codable")
struct ExternalMCPServerCodableTests {
private static func makeCoders() -> (JSONEncoder, JSONDecoder) {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (encoder, decoder)
}
@Test("Decodes old-shape JSON (saved before env/HTTP fields existed) with safe defaults")
func decodesOldShapeJSON() throws {
// Exactly the 7 keys ExternalMCPServer had before transportKind/env/url/bearerToken/headers
// were added this is what's actually sitting in existing users' settings DB right now.
let oldJSON = """
{
"id": "00000000-0000-0000-0000-000000000001",
"name": "Safari",
"command": "safaridriver",
"args": ["--mcp"],
"isEnabled": true,
"timeout": 30,
"createdAt": "2026-01-01T00:00:00Z"
}
"""
let (_, decoder) = Self.makeCoders()
let server = try decoder.decode(ExternalMCPServer.self, from: Data(oldJSON.utf8))
#expect(server.name == "Safari")
#expect(server.command == "safaridriver")
#expect(server.args == ["--mcp"])
#expect(server.transportKind == .stdio)
#expect(server.env.isEmpty)
#expect(server.url.isEmpty)
#expect(server.bearerToken.isEmpty)
#expect(server.headers.isEmpty)
}
@Test("An array of old-shape servers (the real settings-JSON shape) decodes without dropping any")
func decodesOldShapeArray() throws {
let oldJSON = """
[
{"id": "00000000-0000-0000-0000-000000000001", "name": "Safari", "command": "safaridriver",
"args": ["--mcp"], "isEnabled": true, "timeout": 30, "createdAt": "2026-01-01T00:00:00Z"},
{"id": "00000000-0000-0000-0000-000000000002", "name": "Other", "command": "some-tool",
"args": [], "isEnabled": false, "timeout": 15, "createdAt": "2026-02-01T00:00:00Z"}
]
"""
let (_, decoder) = Self.makeCoders()
let servers = try decoder.decode([ExternalMCPServer].self, from: Data(oldJSON.utf8))
#expect(servers.count == 2)
}
@Test("Round-trips a full HTTP-transport server through encode/decode")
func roundTripsHTTPServer() throws {
let original = ExternalMCPServer(
name: "Obsidian",
transportKind: .http,
url: "http://127.0.0.1:27123/mcp/",
bearerToken: "secret-token",
headers: ["X-Extra": "value"],
timeout: 45
)
let (encoder, decoder) = Self.makeCoders()
let data = try encoder.encode(original)
let decoded = try decoder.decode(ExternalMCPServer.self, from: data)
#expect(decoded.id == original.id)
#expect(decoded.transportKind == .http)
#expect(decoded.url == "http://127.0.0.1:27123/mcp/")
#expect(decoded.bearerToken == "secret-token")
#expect(decoded.headers == ["X-Extra": "value"])
}
@Test("Round-trips a stdio-transport server with env vars")
func roundTripsStdioServerWithEnv() throws {
let original = ExternalMCPServer(
name: "Custom",
command: "npx",
args: ["-y", "some-tool"],
env: ["API_KEY": "abc123"],
timeout: 30
)
let (encoder, decoder) = Self.makeCoders()
let data = try encoder.encode(original)
let decoded = try decoder.decode(ExternalMCPServer.self, from: data)
#expect(decoded.transportKind == .stdio)
#expect(decoded.env == ["API_KEY": "abc123"])
#expect(decoded.command == "npx")
#expect(decoded.args == ["-y", "some-tool"])
}
}
+67
View File
@@ -0,0 +1,67 @@
//
// JarvisModelsTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("JarvisAgentRun decoding")
struct JarvisModelsTests {
// Real shape returned by the oAI-Web /api/agents/{id}/runs endpoint confirmed by Rune
// against a live run. Guards against the "result"/"output" and "ended_at"/"finished_at"
// field-name mismatch that shipped in the initial Run Details modal (output always showed
// "No output for this run" even when the API had real content).
private static let sampleJSON = """
{
"id": "8628fa26-8ff1-4053-b106-8ba84a0e10e0",
"agent_id": "531c884b-a401-4985-8717-bfa6bcc1d148",
"started_at": "2026-08-07T10:00:00.135999+00:00",
"ended_at": "2026-08-07T10:00:13.815956+00:00",
"status": "success",
"input_tokens": 25615,
"output_tokens": 1233,
"cost_usd": 0.031780000776052475,
"result": "Infrastructure Status: HEALTHY",
"error": null,
"model": "anthropic:claude-haiku-4-5-20251001"
}
"""
@Test("Decodes the API's 'result' field into .output")
func decodesResultIntoOutput() throws {
let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8))
#expect(run.output == "Infrastructure Status: HEALTHY")
}
@Test("Decodes the API's 'ended_at' field into .finishedAt")
func decodesEndedAtIntoFinishedAt() throws {
let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8))
#expect(run.finishedAt == "2026-08-07T10:00:13.815956+00:00")
}
@Test("Decodes a full real run without losing any field")
func decodesAllFields() throws {
let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8))
#expect(run.id == "8628fa26-8ff1-4053-b106-8ba84a0e10e0")
#expect(run.agentId == "531c884b-a401-4985-8717-bfa6bcc1d148")
#expect(run.status == "success")
#expect(run.startedAt == "2026-08-07T10:00:00.135999+00:00")
#expect(run.inputTokens == 25615)
#expect(run.outputTokens == 1233)
#expect(run.totalTokens == 26848)
#expect(run.costUsd == 0.031780000776052475)
#expect(run.error == nil)
}
@Test("A finished run with both started_at and ended_at produces a non-nil duration")
func computesDurationFromRealFieldNames() throws {
let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8))
#expect(run.formattedDuration != nil)
#expect(run.formattedDuration == "13s")
}
}
+131
View File
@@ -0,0 +1,131 @@
//
// MCPTransportTests.swift
// ConfabTests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("MCPTransportSupport.extractResult")
struct MCPTransportSupportTests {
@Test("Extracts the result payload from a successful JSON-RPC response")
func extractsResult() throws {
let json: [String: Any] = ["jsonrpc": "2.0", "id": 1, "result": ["tools": []]]
let data = try MCPTransportSupport.extractResult(from: json)
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
#expect(decoded?["tools"] != nil)
}
@Test("Throws with the server's message when the response is a JSON-RPC error")
func throwsOnJSONRPCError() {
let json: [String: Any] = ["jsonrpc": "2.0", "id": 1, "error": ["code": -32601, "message": "Method not found"]]
do {
_ = try MCPTransportSupport.extractResult(from: json)
Issue.record("Expected extractResult to throw")
} catch let error as MCPClientError {
switch error {
case .invalidResponse(let message): #expect(message == "Method not found")
default: Issue.record("Expected .invalidResponse, got \(error)")
}
} catch {
Issue.record("Expected MCPClientError, got \(error)")
}
}
@Test("Throws when the response has neither result nor error")
func throwsOnMissingResult() {
let json: [String: Any] = ["jsonrpc": "2.0", "id": 1]
do {
_ = try MCPTransportSupport.extractResult(from: json)
Issue.record("Expected extractResult to throw")
} catch let error as MCPClientError {
switch error {
case .invalidResponse(let message): #expect(message == "Missing result field")
default: Issue.record("Expected .invalidResponse, got \(error)")
}
} catch {
Issue.record("Expected MCPClientError, got \(error)")
}
}
}
@Suite("HTTPMCPTransport.parseResponseBody")
struct HTTPMCPTransportParseTests {
@Test("Parses a plain application/json response body directly")
func parsesPlainJSON() throws {
let body = Data(#"{"jsonrpc":"2.0","id":7,"result":{"ok":true}}"#.utf8)
let json = try HTTPMCPTransport.parseResponseBody(body, contentType: "application/json", expectedId: 7)
#expect(json["id"] as? Int == 7)
}
@Test("Parses a text/event-stream body, finding the data: line matching the expected id")
func parsesSSEMatchingId() throws {
let sse = """
event: message
data: {"jsonrpc":"2.0","id":7,"result":{"ok":true}}
"""
let json = try HTTPMCPTransport.parseResponseBody(
Data(sse.utf8), contentType: "text/event-stream", expectedId: 7
)
#expect(json["id"] as? Int == 7)
}
@Test("Skips unrelated server-sent messages before the matching response, per the Streamable HTTP spec")
func skipsUnrelatedMessagesInSSE() throws {
// Spec: "The server MAY send JSON-RPC requests and notifications before sending the
// JSON-RPC response." Simulated here as an unrelated id=99 message before the real id=7 one.
let sse = """
data: {"jsonrpc":"2.0","id":99,"method":"unrelated/notification"}
data: {"jsonrpc":"2.0","id":7,"result":{"ok":true}}
"""
let json = try HTTPMCPTransport.parseResponseBody(
Data(sse.utf8), contentType: "text/event-stream", expectedId: 7
)
#expect(json["id"] as? Int == 7)
}
@Test("Throws when no SSE data: line matches the expected id")
func throwsWhenNoMatchInSSE() {
let sse = """
data: {"jsonrpc":"2.0","id":99,"result":{}}
"""
do {
_ = try HTTPMCPTransport.parseResponseBody(
Data(sse.utf8), contentType: "text/event-stream", expectedId: 7
)
Issue.record("Expected parseResponseBody to throw")
} catch let error as MCPClientError {
switch error {
case .invalidResponse: break
default: Issue.record("Expected .invalidResponse, got \(error)")
}
} catch {
Issue.record("Expected MCPClientError, got \(error)")
}
}
@Test("Throws on malformed JSON in a plain application/json body")
func throwsOnMalformedJSON() {
let body = Data("not json".utf8)
do {
_ = try HTTPMCPTransport.parseResponseBody(body, contentType: "application/json", expectedId: 1)
Issue.record("Expected parseResponseBody to throw")
} catch let error as MCPClientError {
switch error {
case .invalidResponse: break
default: Issue.record("Expected .invalidResponse, got \(error)")
}
} catch {
Issue.record("Expected MCPClientError, got \(error)")
}
}
}
+60
View File
@@ -0,0 +1,60 @@
//
// ThinkingVerbsTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("ThinkingVerbs language selection")
struct ThinkingVerbsTests {
private let supportedCodes = ["en", "nb", "sv", "da", "de", "fr"]
@Test("Every supported language has its own non-empty verb list")
func perLanguageListsAreNonEmpty() {
for code in supportedCodes {
#expect(!ThinkingVerbs.verbs(for: code).isEmpty)
}
}
@Test("Unknown language codes fall back to English")
func unknownCodeFallsBackToEnglish() {
#expect(ThinkingVerbs.verbs(for: "xx") == ThinkingVerbs.verbs(for: "en"))
#expect(ThinkingVerbs.verbs(for: "") == ThinkingVerbs.verbs(for: "en"))
}
@Test("Each supported language's list is genuinely distinct, not a fallback copy of English")
func perLanguageListsAreDistinctFromEnglish() {
let english = ThinkingVerbs.verbs(for: "en")
for code in supportedCodes where code != "en" {
#expect(ThinkingVerbs.verbs(for: code) != english)
}
}
@Test("Language lists are comparable in size — none is a token stub")
func perLanguageListsAreComparablyMore() {
let minimumCount = 40
for code in supportedCodes {
#expect(ThinkingVerbs.verbs(for: code).count >= minimumCount)
}
}
@Test("No duplicate entries within a single language's list")
func noDuplicatesWithinLanguage() {
for code in supportedCodes {
let list = ThinkingVerbs.verbs(for: code)
#expect(Set(list).count == list.count)
}
}
@Test("random() returns a non-empty string ending in an ellipsis")
func randomProducesEllipsisSuffixedString() {
let result = ThinkingVerbs.random()
#expect(!result.isEmpty)
#expect(result.hasSuffix("..."))
}
}