fix: mcp
This commit is contained in:
1 parent
a6f13b8dd3
commit
b2b3927db0
22 files changed
+414
-71
No files matched your search
+1
-1
Submodule AgentKit updated: 3661d15620...42769e5e36.
@@ -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}")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -68,11 +68,15 @@ public final class AIPersistence {
|
||||
)
|
||||
let oauthRows = try context.fetch(FetchDescriptor<PersistentMCPOAuthConfiguration>())
|
||||
let oauth = Dictionary(oauthRows.map { ($0.serverID, $0.value) }, uniquingKeysWith: { first, _ in first })
|
||||
let metadataRows = try context.fetch(FetchDescriptor<PersistentMCPServerMetadata>())
|
||||
let descriptions = Dictionary(
|
||||
metadataRows.map { ($0.serverID, $0.serverDescription) }, uniquingKeysWith: { first, _ in first })
|
||||
var seenMCPServers: Set<UUID> = []
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[..<start]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !prefix.isEmpty { message = prefix }
|
||||
if let end = trimmed.lastIndex(of: "}"), start <= end,
|
||||
trimmed.utf8.count <= 65_536,
|
||||
let object = try? JSONSerialization.jsonObject(with: Data(trimmed[start...end].utf8))
|
||||
as? [String: Any]
|
||||
{
|
||||
let error = object["error"] as? [String: Any]
|
||||
let candidates = [error?["message"], object["message"], object["error"]]
|
||||
if let detail = candidates.compactMap({ $0 as? String })
|
||||
.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
||||
{
|
||||
message = detail
|
||||
}
|
||||
}
|
||||
}
|
||||
let singleLine = message.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||
guard !singleLine.isEmpty else { return String(localized: AppStrings.Assistant.agentError) }
|
||||
return singleLine.count > 160 ? String(singleLine.prefix(159)) + "\u{2026}" : singleLine
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -452,7 +452,7 @@ extension AppStrings {
|
||||
}
|
||||
|
||||
public static var mcpEndpointUrl: LocalizedStringResource {
|
||||
"MCP endpoint URL"
|
||||
"Endpoint URL"
|
||||
}
|
||||
|
||||
public static var mergeWithThisICloudAccount: LocalizedStringResource {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user