diff --git a/AgentKit b/AgentKit index 3661d156..42769e5e 160000 --- a/AgentKit +++ b/AgentKit @@ -1 +1 @@ -Subproject commit 3661d156202e9da8d59e5e36ac461913c8bd8536 +Subproject commit 42769e5e3633f6958b98b804cb044ca58c0bc6a5 diff --git a/FrameworkTests/AssistantFailurePresentationTests.swift b/FrameworkTests/AssistantFailurePresentationTests.swift new file mode 100644 index 00000000..c722a072 --- /dev/null +++ b/FrameworkTests/AssistantFailurePresentationTests.swift @@ -0,0 +1,33 @@ +import Foundation +import TermindPresentation +import Testing + +@Suite @MainActor +struct AssistantFailurePresentationTests { + @Test func endpointFailureUsesTheRemoteMessageInsteadOfTheJSONEnvelope() { + let reason = + #"The model endpoint returned 400: {"error":{"message":"Missing required parameter: 'tools[48].description'.","code":"invalid_request_body"}}"# + #expect( + AssistantFailurePresentation.summary(for: reason) == "Missing required parameter: 'tools[48].description'.") + } + + @Test(arguments: [ + (#"{"message":"Request timed out"}"#, "Request timed out"), + (#"{"error":"Permission denied"}"#, "Permission denied"), + (#"{"error":{"message":" Rate limit\nexceeded "}}"#, "Rate limit exceeded"), + ("Connection refused\nAdditional diagnostics", "Connection refused"), + (#"The endpoint returned 500: {"broken"#, "The endpoint returned 500:"), + (#"The endpoint returned 503: {"error":{"code":123}}"#, "The endpoint returned 503:"), + ]) + func summariesHandleDifferentErrorShapes(_ reason: String, _ expected: String) { + #expect(AssistantFailurePresentation.summary(for: reason) == expected) + } + + @Test func emptyDiagnosticUsesLocalizedFallbackAndLongMessagesAreBounded() { + #expect( + AssistantFailurePresentation.summary(for: " \n\t ") + == String(localized: AppStrings.Assistant.agentError)) + let message = String(repeating: "A", count: 200) + #expect(AssistantFailurePresentation.summary(for: message) == String(repeating: "A", count: 159) + "\u{2026}") + } +} diff --git a/FrameworkTests/MCPServerMetadataTests.swift b/FrameworkTests/MCPServerMetadataTests.swift new file mode 100644 index 00000000..2865667d --- /dev/null +++ b/FrameworkTests/MCPServerMetadataTests.swift @@ -0,0 +1,71 @@ +import AgentKit +import Foundation +import SwiftData +import TermindApplication +import TermindDomain +import TermindPersistence +import Testing + +@Suite @MainActor +struct MCPServerMetadataTests { + @Test func rediscoveryReplacesAndClearsTheDescriptionWithoutResettingAccessPolicy() { + let tool = MCPTool(id: "search", accessPolicy: .approve) + let server = MCPServer(name: "Docs", serverDescription: "Old description", tools: [tool]) + let model = AIConfigurationModel(mcpServers: [server]) + model.setTools( + [MCPTool(id: tool.id)], version: "2026-07-28", serverDescription: "Search documents.", for: server.id) + #expect(model.mcpServer(server.id)?.serverDescription == "Search documents.") + #expect(model.mcpServer(server.id)?.tools.first?.accessPolicy == .approve) + model.setTools([MCPTool(id: tool.id)], version: "2026-07-28", for: server.id) + #expect(model.mcpServer(server.id)?.serverDescription == nil) + } + + @Test func syncedMetadataRoundTripsAndOlderPayloadsRemainReadable() throws { + let server = MCPServer(name: "Docs", serverDescription: "Search documents.") + let encoded = try JSONEncoder().encode(SyncedMCPServer(server, sortIndex: 0)) + let decoded = try JSONDecoder().decode(SyncedMCPServer.self, from: encoded) + #expect(decoded.value.serverDescription == server.serverDescription) + var old = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + old.removeValue(forKey: "serverDescription") + let legacy = try JSONDecoder().decode( + SyncedMCPServer.self, from: JSONSerialization.data(withJSONObject: old)) + #expect(legacy.value.serverDescription == nil) + #expect(legacy.value.id == server.id) + } + + @Test func historicalServerSurvivesMigrationAndDescriptionSurvivesReopening() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("metadata.store") + let server = MCPServer( + name: "Docs", url: "https://docs.test/mcp", + tools: [MCPTool(id: "search", accessPolicy: .approve)]) + try writeHistoricalServer(server, to: url) + try upgradeAndSaveDescription(server, at: url) + let reopened = try TermindPersistence.makeContainer(url: url) + let restored = try #require(AIPersistence(context: reopened.mainContext).load().mcpServers.first) + #expect(restored.id == server.id) + #expect(restored.tools == server.tools) + #expect(restored.serverDescription == "Search documents.") + } + + private func writeHistoricalServer(_ server: MCPServer, to url: URL) throws { + let schema = Schema(versionedSchema: TermindSchemaV58.self) + let container = try ModelContainer( + for: schema, + configurations: [ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)]) + container.mainContext.insert(PersistentMCPServer(server, sortIndex: 0)) + try container.mainContext.save() + } + + private func upgradeAndSaveDescription(_ server: MCPServer, at url: URL) throws { + let container = try TermindPersistence.makeContainer(url: url) + let store = AIPersistence(context: container.mainContext) + var snapshot = try store.load() + #expect(snapshot.mcpServers.first?.id == server.id) + #expect(snapshot.mcpServers.first?.serverDescription == nil) + snapshot.mcpServers[0].serverDescription = "Search documents." + try store.save(snapshot) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 2261d029..a49c5dfe 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -12317,6 +12317,88 @@ } } }, + "Called" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufgerufen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appelé" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "呼び出し済み" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "호출 완료" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已调用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已呼叫" + } + } + } + }, + "Calling" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wird aufgerufen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appel en cours" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "呼び出し中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "호출 중" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在调用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在呼叫" + } + } + } + }, "Calls appear here after an MCP client invokes a tool." : { "extractionState" : "manual", "localizations" : { @@ -27766,6 +27848,47 @@ } } }, + "Endpoint URL" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Endpunkt-URL" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL du point de terminaison" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "エンドポイントURL" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "엔드포인트 URL" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "端点URL" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "端點URL" + } + } + } + }, "Enjoying Termind?" : { "extractionState" : "manual", "localizations" : { @@ -39773,47 +39896,6 @@ } } }, - "MCP endpoint URL" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "MCP-Endpunkt-URL" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL du point de terminaison MCP" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "MCPエンドポイントURL" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "MCP 엔드포인트 URL" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "MCP端点URL" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "MCP端點URL" - } - } - } - }, "MagicDNS" : { "extractionState" : "manual", "localizations" : { @@ -87169,6 +87251,47 @@ } } }, + "call" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "aufrufen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "appeler" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "呼び出し" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "호출" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "调用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "呼叫" + } + } + } + }, "change permissions for" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift b/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift index c537eb03..a4de33d7 100644 --- a/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift @@ -712,7 +712,9 @@ public final class AIConfigurationModel { /// reconnect would silently undo the decision made about this server. A /// genuinely new tool uses its Termind default: a non-destructive tool with /// `readOnlyHint == true` is approved, while every other tool asks. - public func setTools(_ discovered: [MCPTool], version: String, for id: MCPServer.ID) { + public func setTools( + _ discovered: [MCPTool], version: String, serverDescription: String? = nil, for id: MCPServer.ID + ) { mutatePersistedState { guard let index = mcpServers.firstIndex(where: { $0.id == id }) else { return } let previous = Dictionary( @@ -726,6 +728,7 @@ public final class AIConfigurationModel { return merged } mcpServers[index].negotiatedVersion = version + mcpServers[index].serverDescription = serverDescription mcpServers[index].lastConnectedAt = Date() } } diff --git a/Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift b/Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift index be18ab40..5bd29ab5 100644 --- a/Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift +++ b/Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift @@ -118,6 +118,7 @@ public nonisolated struct SyncedMCPServer: Codable, Hashable, Sendable { public var id: UUID public var name: String public var namespaceID: String + public var serverDescription: String? public var transport: MCPServer.Transport public var url: String public var isEnabled: Bool @@ -131,6 +132,7 @@ public nonisolated struct SyncedMCPServer: Codable, Hashable, Sendable { id = server.id name = server.name namespaceID = server.namespaceID + serverDescription = server.serverDescription transport = server.transport url = server.url isEnabled = server.isEnabled @@ -143,7 +145,7 @@ public nonisolated struct SyncedMCPServer: Codable, Hashable, Sendable { public var value: MCPServer { MCPServer( - id: id, name: name, namespaceID: namespaceID, transport: transport, + id: id, name: name, namespaceID: namespaceID, serverDescription: serverDescription, transport: transport, url: url, isEnabled: isEnabled, credentialRef: credentialRef, headers: headers, tools: tools, oauth: oauth) } } diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index 7e627e04..a7d2397c 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -948,6 +948,14 @@ public enum TermindSchemaV58: VersionedSchema { } } +/// Retains optional MCP implementation descriptions discovered from remote servers. +public enum TermindSchemaV59: VersionedSchema { + public static let versionIdentifier = Schema.Version(59, 0, 0) + public static var models: [any PersistentModel.Type] { + TermindSchemaV58.models + [PersistentMCPServerMetadata.self] + } +} + public enum TermindMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { [ @@ -966,7 +974,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { TermindSchemaV46.self, TermindSchemaV47.self, TermindSchemaV48.self, TermindSchemaV49.self, TermindSchemaV50.self, TermindSchemaV51.self, TermindSchemaV52.self, TermindSchemaV53.self, TermindSchemaV54.self, TermindSchemaV55.self, TermindSchemaV56.self, TermindSchemaV57.self, - TermindSchemaV58.self, + TermindSchemaV58.self, TermindSchemaV59.self, ] } @@ -1084,6 +1092,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { MigrationStage.lightweight(fromVersion: TermindSchemaV55.self, toVersion: TermindSchemaV56.self), MigrationStage.lightweight(fromVersion: TermindSchemaV56.self, toVersion: TermindSchemaV57.self), MigrationStage.lightweight(fromVersion: TermindSchemaV57.self, toVersion: TermindSchemaV58.self), + MigrationStage.lightweight(fromVersion: TermindSchemaV58.self, toVersion: TermindSchemaV59.self), ] } } diff --git a/Frameworks/TermindPersistence/PersistentMCPServerMetadata.swift b/Frameworks/TermindPersistence/PersistentMCPServerMetadata.swift new file mode 100644 index 00000000..40d531ed --- /dev/null +++ b/Frameworks/TermindPersistence/PersistentMCPServerMetadata.swift @@ -0,0 +1,14 @@ +import Foundation +import SwiftData + +/// Discovery metadata is separate so historical MCP server schemas remain unchanged. +@Model +public final class PersistentMCPServerMetadata { + @Attribute(.unique) public var serverID: UUID = UUID() + public var serverDescription = "" + + public init(serverID: UUID, serverDescription: String) { + self.serverID = serverID + self.serverDescription = serverDescription + } +} diff --git a/Frameworks/TermindPersistence/SwiftDataAIConfigurationStore.swift b/Frameworks/TermindPersistence/SwiftDataAIConfigurationStore.swift index b032b402..00c7d970 100644 --- a/Frameworks/TermindPersistence/SwiftDataAIConfigurationStore.swift +++ b/Frameworks/TermindPersistence/SwiftDataAIConfigurationStore.swift @@ -68,11 +68,15 @@ public final class AIPersistence { ) let oauthRows = try context.fetch(FetchDescriptor()) let oauth = Dictionary(oauthRows.map { ($0.serverID, $0.value) }, uniquingKeysWith: { first, _ in first }) + let metadataRows = try context.fetch(FetchDescriptor()) + let descriptions = Dictionary( + metadataRows.map { ($0.serverID, $0.serverDescription) }, uniquingKeysWith: { first, _ in first }) var seenMCPServers: Set = [] let mcpServers = mcpRows.compactMap { row -> MCPServer? in guard seenMCPServers.insert(row.id).inserted else { return nil } var server = row.value server.oauth = oauth[row.id] + server.serverDescription = descriptions[row.id] return server } @@ -98,6 +102,7 @@ public final class AIPersistence { try context.delete(model: PersistentAISelection.self) try context.delete(model: PersistentMCPServer.self) try context.delete(model: PersistentMCPOAuthConfiguration.self) + try context.delete(model: PersistentMCPServerMetadata.self) for (providerIndex, provider) in snapshot.providers.enumerated() { // A provider with no recorded endpoint is read back as @@ -122,6 +127,9 @@ public final class AIPersistence { context.insert(PersistentAISelection(snapshot.lastSelectedModel)) for (serverIndex, server) in snapshot.mcpServers.enumerated() { context.insert(PersistentMCPServer(server, sortIndex: serverIndex)) + if let description = server.serverDescription { + context.insert(PersistentMCPServerMetadata(serverID: server.id, serverDescription: description)) + } if let configuration = server.oauth { context.insert(PersistentMCPOAuthConfiguration(serverID: server.id, configuration: configuration)) } diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index 5851465d..a74f1bcb 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -20,7 +20,7 @@ public enum TermindPersistence { #endif // Local schema migrations do not change the independently versioned CloudKit envelopes. - public static let schema = Schema(versionedSchema: TermindSchemaV58.self) + public static let schema = Schema(versionedSchema: TermindSchemaV59.self) public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer { precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL") diff --git a/Frameworks/TermindPresentation/AgentToolPresentation.swift b/Frameworks/TermindPresentation/AgentToolPresentation.swift index e268e7bc..5ff7926c 100644 --- a/Frameworks/TermindPresentation/AgentToolPresentation.swift +++ b/Frameworks/TermindPresentation/AgentToolPresentation.swift @@ -1138,6 +1138,11 @@ nonisolated public enum AgentToolPresentation { if usesLocalizedActionTemplates(locale) { return localizedActionVerb(kind, locale: locale) } let forms: ActionForms = switch kind { + case .call: + .init( + infinitive: localized(AppStrings.MCP.callVerb, locale: locale), + progressive: localized(AppStrings.MCP.calling, locale: locale), + past: localized(AppStrings.MCP.called, locale: locale)) case .cancel: .init(infinitive: "cancel", progressive: "Cancelling", past: "Cancelled") case .ask: .init(infinitive: "ask", progressive: "Asking", past: "Asked") case .compare: .init(infinitive: "compare", progressive: "Comparing", past: "Compared") @@ -1185,6 +1190,7 @@ nonisolated public enum AgentToolPresentation { _ kind: AgentToolDescriptor.Presentation.ActionKind, locale: Locale ) -> String { switch kind { + case .call: localized(AppStrings.MCP.callVerb, locale: locale) case .cancel: localized(AppStrings.Common.cancel, locale: locale) case .ask: localized("ask", locale: locale) case .compare: localized("compare", locale: locale) diff --git a/Frameworks/TermindPresentation/AssistantFailurePresentation.swift b/Frameworks/TermindPresentation/AssistantFailurePresentation.swift new file mode 100644 index 00000000..50f89e54 --- /dev/null +++ b/Frameworks/TermindPresentation/AssistantFailurePresentation.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Compact disclosure titles derived from diagnostics, without changing the full error detail. +public enum AssistantFailurePresentation { + public static func summary(for reason: String) -> String { + let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) + var message = trimmed.components(separatedBy: .newlines).first ?? "" + if let start = trimmed.firstIndex(of: "{") { + let prefix = String(trimmed[.. 160 ? String(singleLine.prefix(159)) + "\u{2026}" : singleLine + } +} diff --git a/Frameworks/TermindPresentation/Strings/MCP.swift b/Frameworks/TermindPresentation/Strings/MCP.swift index c5a55b1c..086c0d34 100644 --- a/Frameworks/TermindPresentation/Strings/MCP.swift +++ b/Frameworks/TermindPresentation/Strings/MCP.swift @@ -2,6 +2,9 @@ import Foundation extension AppStrings { public nonisolated enum MCP { + public static var callVerb: LocalizedStringResource { "call" } + public static var calling: LocalizedStringResource { "Calling" } + public static var called: LocalizedStringResource { "Called" } public static var oauth: LocalizedStringResource { "OAuth" } public static var oauthAuthorization: LocalizedStringResource { "OAuth Authorization" } public static var oauthSignedIn: LocalizedStringResource { "Signed in on this device" } diff --git a/Frameworks/TermindPresentation/Strings/Settings.swift b/Frameworks/TermindPresentation/Strings/Settings.swift index a4a55cfd..137ac0b4 100644 --- a/Frameworks/TermindPresentation/Strings/Settings.swift +++ b/Frameworks/TermindPresentation/Strings/Settings.swift @@ -452,7 +452,7 @@ extension AppStrings { } public static var mcpEndpointUrl: LocalizedStringResource { - "MCP endpoint URL" + "Endpoint URL" } public static var mergeWithThisICloudAccount: LocalizedStringResource { diff --git a/TFI/Application/FeatureModels/MobileMCPEditorModel.swift b/TFI/Application/FeatureModels/MobileMCPEditorModel.swift index 672886df..29cb0896 100644 --- a/TFI/Application/FeatureModels/MobileMCPEditorModel.swift +++ b/TFI/Application/FeatureModels/MobileMCPEditorModel.swift @@ -109,6 +109,7 @@ final class MobileMCPEditorModel { return tool } draft.negotiatedVersion = outcome.protocolVersion + draft.serverDescription = outcome.serverDescription draft.lastConnectedAt = .now connection = String( localized: AppStrings.Settings.discoveredTools( @@ -137,6 +138,7 @@ final class MobileMCPEditorModel { for tool in draft.tools { policies[tool.id] = tool.accessPolicy } draft.tools = [] draft.negotiatedVersion = "" + draft.serverDescription = nil draft.lastConnectedAt = nil } @@ -212,6 +214,7 @@ final class MobileMCPEditorModel { if draft.lastConnectedAt != original.lastConnectedAt { value.lastConnectedAt = draft.lastConnectedAt value.negotiatedVersion = draft.negotiatedVersion + value.serverDescription = draft.serverDescription } } value.url = value.url.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/TFI/Features/AI/MobileAssistantConversation.swift b/TFI/Features/AI/MobileAssistantConversation.swift index 616eb4c7..ee62f322 100644 --- a/TFI/Features/AI/MobileAssistantConversation.swift +++ b/TFI/Features/AI/MobileAssistantConversation.swift @@ -710,20 +710,16 @@ struct MobileAssistantConversation: View { .foregroundStyle(.red) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) - AssistantCopyButton(text: failure.reason) { - UIPasteboard.general.string = $0 - } - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .foregroundStyle(.secondary) } .padding(.leading, 24) } label: { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") .foregroundStyle(.red) - Text(AppStrings.Assistant.agentError) + Text(verbatim: AssistantFailurePresentation.summary(for: failure.reason)) .foregroundStyle(.primary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) } .font(.callout) } diff --git a/TFITests/MobileAISettingsTests.swift b/TFITests/MobileAISettingsTests.swift index c3875837..bfd89780 100644 --- a/TFITests/MobileAISettingsTests.swift +++ b/TFITests/MobileAISettingsTests.swift @@ -328,7 +328,7 @@ struct MobileAISettingsTests { #expect(request.url == "https://draft.invalid/mcp") #expect(token == "draft-token") return MCPDiscovery( - protocolVersion: "test", serverName: "Test", + protocolVersion: "test", serverName: "Test", serverDescription: "Manage gateway tools.", tools: [ MCPTool(id: "existing"), MCPTool(id: "new"), @@ -350,11 +350,15 @@ struct MobileAISettingsTests { while editor.running { await Task.yield() } #expect(ai.mcpServers.isEmpty) #expect(editor.draft.tools.map(\.accessPolicy) == [.deny, .ask, .approve, .ask]) + #expect(editor.draft.serverDescription == "Manage gateway tools.") let conflictingIndex = try #require(editor.draft.tools.firstIndex { $0.id == "conflicting" }) editor.draft.tools[conflictingIndex].accessPolicy = .approve try editor.save() #expect(ai.mcpServer(server.id)?.namespaceID == "gateway") + #expect(ai.mcpServer(server.id)?.serverDescription == "Manage gateway tools.") #expect(ai.mcpServer(server.id)?.tools.map(\.accessPolicy) == [.deny, .ask, .approve, .approve]) + editor.invalidateConnection() + #expect(editor.draft.serverDescription == nil) } @Test func mcpCancellationAndRemoteDeletionCannotRestoreResults() async { diff --git a/TFM/Features/AI/TranscriptNativeRows.swift b/TFM/Features/AI/TranscriptNativeRows.swift index 18dbce53..921a169a 100644 --- a/TFM/Features/AI/TranscriptNativeRows.swift +++ b/TFM/Features/AI/TranscriptNativeRows.swift @@ -1358,7 +1358,7 @@ private enum NativeTranscriptFactory { let expanded = context.presentation.disclosures.isExpanded(disclosureID) let header = NativeDisclosureHeader( symbol: "exclamationmark.triangle", - title: String(localized: AppStrings.Assistant.agentError), + title: AssistantFailurePresentation.summary(for: failure.reason), iconColor: .systemRed, titleColor: .labelColor, expanded: expanded, @@ -1378,17 +1378,16 @@ private enum NativeTranscriptFactory { top: 0, left: Metrics.Sidebar.rowIcon, bottom: 0, right: 0 ) detail.addArrangedSubview(NativeVerticalRule()) - detail.addArrangedSubview( - NativeLabel( - failure.reason, - font: .monospacedSystemFont( - ofSize: NSFont.smallSystemFontSize, - weight: .regular - ), - color: .systemRed, - selectable: true - )) - detail.addArrangedSubview(NativeCopyButton(text: failure.reason)) + let label = NativeLabel( + failure.reason, + font: .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular), + color: .systemRed, + selectable: true + ) + label.setContentHuggingPriority(.defaultLow, for: .horizontal) + detail.addArrangedSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + label.trailingAnchor.constraint(equalTo: detail.trailingAnchor).isActive = true stack.addArrangedSubview(detail) } return stack diff --git a/TFM/Features/Settings/MCPServerEditorSheet.swift b/TFM/Features/Settings/MCPServerEditorSheet.swift index 07151566..74c2dc9e 100644 --- a/TFM/Features/Settings/MCPServerEditorSheet.swift +++ b/TFM/Features/Settings/MCPServerEditorSheet.swift @@ -385,7 +385,9 @@ struct MCPServerEditorPage: View { guard !Task.isCancelled, generation == session.generation, app.aiConfiguration.mcpServer(serverID) == server else { return } - app.aiConfiguration.setTools(discovery.tools, version: discovery.protocolVersion, for: serverID) + app.aiConfiguration.setTools( + discovery.tools, version: discovery.protocolVersion, + serverDescription: discovery.serverDescription, for: serverID) app.aiConfiguration.setMCPStatus(.connected, for: serverID) session.connection = .succeeded( String( diff --git a/TFMTests/AgentToolPresentationTests.swift b/TFMTests/AgentToolPresentationTests.swift index d2b3e5d1..a39cae0f 100644 --- a/TFMTests/AgentToolPresentationTests.swift +++ b/TFMTests/AgentToolPresentationTests.swift @@ -12,6 +12,44 @@ import struct TermindDomain.Host @Suite struct AgentToolPresentationTests { + @Test + func mcpCallsUseCallingAndCalledWithoutChangingCommandVerbs() { + var card = makeCard( + name: "docs.search", target: .mcp, + presentation: .init(symbol: "puzzlepiece.extension", activity: .label("docs.search"), actionKind: .call)) + let english = Locale(identifier: "en") + card.state = .running + #expect(AgentToolPresentation.summary(for: card, locale: english).hasPrefix("Calling ")) + card.state = .completed + #expect(AgentToolPresentation.summary(for: card, locale: english).hasPrefix("Called ")) + #expect(AgentToolPresentation.batchSummary(for: [card, card], locale: english).hasPrefix("Called ")) + card.state = .failed + #expect(AgentToolPresentation.summary(for: card, locale: english).hasPrefix("Failed to call ")) + card.state = .waitingForApproval + #expect(AgentToolPresentation.summary(for: card, locale: english).hasPrefix("Waiting to call ")) + + card.descriptor.target = .local + card.descriptor.presentation?.actionKind = .run + card.state = .running + #expect(AgentToolPresentation.summary(for: card, locale: english).hasPrefix("Running ")) + card.state = .completed + #expect(AgentToolPresentation.summary(for: card, locale: english).hasPrefix("Ran ")) + } + + @Test(arguments: ["zh-Hans", "zh-Hant"]) + func mcpCallVerbsAreLocalized(_ language: String) { + var card = makeCard( + name: "docs.search", target: .mcp, + presentation: .init(symbol: "puzzlepiece.extension", activity: .label("docs.search"), actionKind: .call)) + let locale = Locale(identifier: language) + card.state = .running + let running = language == "zh-Hans" ? "正在调用docs.search" : "正在呼叫docs.search" + #expect(AgentToolPresentation.summary(for: card, locale: locale) == running) + card.state = .completed + let expected = language == "zh-Hans" ? "已调用docs.search" : "已呼叫docs.search" + #expect(AgentToolPresentation.summary(for: card, locale: locale) == expected) + } + @Test func approvalUsesTheRegisteredToolArgumentProjectionWithoutRedactingIt() throws { let hostID = UUID() diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index a0a02377..fba016cc 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -3021,7 +3021,7 @@ repositoryURL = "https://github.com/AkinoKaede/AgentKit.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 3.0.1; + minimumVersion = 3.0.2; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b0b889fb..c00e60ca 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/AkinoKaede/AgentKit.git", "state" : { - "revision" : "3661d156202e9da8d59e5e36ac461913c8bd8536", - "version" : "3.0.1" + "revision" : "1bcf65a06a68b15f7d8010c8ebb82c70b440bc90", + "version" : "3.0.2" } }, {