diff --git a/CHANGELOG.md b/CHANGELOG.md index 654ed4377..e0e7d5e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - AI Chat: persisted conversations now carry a schema version so future migrations can read older files cleanly - AI Chat: custom slash commands reject duplicate names, including case-insensitive collisions on rename - Internal: unify JSON value type used by AI tools and MCP wire +- Internal: shared schema builder for AI chat tools, removes ~100 lines of duplicated JSON Schema boilerplate +- Internal: AI chat tools declare their access mode (read-only, write, agent-only) rather than relying on a hardcoded allowlist; new tools are picked up automatically +- AI providers: Anthropic test connection uses the configured model, known model list updated through Claude 4.7, and Ollama detection now logs the actual error category instead of swallowing every failure as 'not running' +- AI Chat views: replace custom pill buttons with native `.borderless` styles, switch hardcoded text colors to semantic system colors, use relative font sizing in Markdown rendering, align spacing to the 8-pt grid, and add accessibility labels to icon-only buttons + +### Fixed + +- AI Chat: `@` mention detection no longer breaks when the cursor sits right after an emoji or other non-BMP character ## [0.39.1] - 2026-05-08 diff --git a/TablePro/Core/AI/AIProvider.swift b/TablePro/Core/AI/AIProvider.swift index baeb49991..3b076f238 100644 --- a/TablePro/Core/AI/AIProvider.swift +++ b/TablePro/Core/AI/AIProvider.swift @@ -4,9 +4,11 @@ // import Foundation +import os enum AIProvider { static let modelListTimeout: TimeInterval = 5.0 + static let logger = Logger(subsystem: "com.TablePro", category: "AIProvider") } enum AIProviderError: Error, LocalizedError { @@ -84,9 +86,16 @@ enum AIProviderError: Error, LocalizedError { extension ChatTransport { func collectErrorBody(from bytes: URLSession.AsyncBytes) async throws -> String { var body = "" + var truncated = false for try await line in bytes.lines { body += line - if (body as NSString).length > 2_000 { break } + if (body as NSString).length > 2_000 { + truncated = true + break + } + } + if truncated { + AIProvider.logger.warning("Error response body truncated at 2000 bytes; full body suppressed") } return body } diff --git a/TablePro/Core/AI/AnthropicProvider.swift b/TablePro/Core/AI/AnthropicProvider.swift index 4b191d21c..6d87297a9 100644 --- a/TablePro/Core/AI/AnthropicProvider.swift +++ b/TablePro/Core/AI/AnthropicProvider.swift @@ -11,12 +11,14 @@ final class AnthropicProvider: ChatTransport { private let endpoint: String private let apiKey: String + private let model: String private let maxOutputTokens: Int private let session: URLSession - init(endpoint: String, apiKey: String, maxOutputTokens: Int = 4_096) { + init(endpoint: String, apiKey: String, model: String = "", maxOutputTokens: Int = 4_096) { self.endpoint = endpoint.normalizedEndpoint() self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + self.model = model.trimmingCharacters(in: .whitespacesAndNewlines) self.maxOutputTokens = maxOutputTokens self.session = URLSession(configuration: .ephemeral) } @@ -100,16 +102,17 @@ final class AnthropicProvider: ChatTransport { } private static let knownModels = [ - "claude-sonnet-4-6", - "claude-opus-4-6", + "claude-opus-4-7-20260101", + "claude-sonnet-4-6-20251101", "claude-haiku-4-5-20251001", "claude-sonnet-4-5-20250929", - "claude-opus-4-5-20251101" + "claude-opus-4-5-20250929" ] func testConnection() async throws -> Bool { + let testModel = model.isEmpty ? (Self.knownModels.first ?? "") : model let testTurn = ChatTurn(role: .user, blocks: [.text("Hi")]) - let testOptions = ChatTransportOptions(model: "claude-haiku-4-5-20251001", maxOutputTokens: 1) + let testOptions = ChatTransportOptions(model: testModel, maxOutputTokens: 1) let request = try buildMessagesRequest(turns: [testTurn], options: testOptions, stream: false) let (data, response) = try await session.data(for: request) diff --git a/TablePro/Core/AI/Chat/ChatTool.swift b/TablePro/Core/AI/Chat/ChatTool.swift index 959a72379..f6047b3c0 100644 --- a/TablePro/Core/AI/Chat/ChatTool.swift +++ b/TablePro/Core/AI/Chat/ChatTool.swift @@ -5,20 +5,46 @@ import Foundation -/// A tool the AI can call from a chat turn. Implementations are registered -/// with `ChatToolRegistry` and exposed to providers via `ChatToolSpec`. +enum ChatToolMode: Sendable { + case readOnly + case write + case agentOnly +} + protocol ChatTool: Sendable { var name: String { get } var description: String { get } var inputSchema: JsonValue { get } + var mode: ChatToolMode { get } func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult } +extension ChatToolMode { + func isAllowed(in chatMode: AIChatMode) -> Bool { + switch (self, chatMode) { + case (_, .agent): + return true + case (.readOnly, .ask), (.readOnly, .edit): + return true + case (.write, .edit): + return true + case (.write, .ask): + return false + case (.agentOnly, .ask), (.agentOnly, .edit): + return false + } + } + + var requiresApproval: Bool { + switch self { + case .readOnly: return false + case .write, .agentOnly: return true + } + } +} + struct ChatToolResult: Sendable, Equatable, Codable { - /// Tool results are UTF-8 text in this version. A future expansion may - /// widen `content` to accept multiple typed blocks (text, image, - /// structured data); treat the current shape as a forward-compat floor. let content: String let isError: Bool @@ -29,7 +55,6 @@ struct ChatToolResult: Sendable, Equatable, Codable { } extension ChatTool { - /// Wire-format spec for `ChatTransportOptions.tools`. var spec: ChatToolSpec { ChatToolSpec(name: name, description: description, inputSchema: inputSchema) } diff --git a/TablePro/Core/AI/Chat/ChatToolContext+Helpers.swift b/TablePro/Core/AI/Chat/ChatToolContext+Helpers.swift new file mode 100644 index 000000000..c745cd175 --- /dev/null +++ b/TablePro/Core/AI/Chat/ChatToolContext+Helpers.swift @@ -0,0 +1,21 @@ +// +// ChatToolContext+Helpers.swift +// TablePro +// + +import Foundation + +extension ChatToolContext { + func resolveConnectionId(_ input: JsonValue) throws -> UUID { + if let connectionId = try? ChatToolArgumentDecoder.requireUUID(input, key: "connection_id") { + return connectionId + } + if let active = connectionId { + return active + } + throw ChatToolArgumentError.missingOrInvalid( + key: "connection_id", + expected: "UUID string (or attach a connection in the chat)" + ) + } +} diff --git a/TablePro/Core/AI/Chat/ChatToolRegistry.swift b/TablePro/Core/AI/Chat/ChatToolRegistry.swift index 058886e3f..ea8391751 100644 --- a/TablePro/Core/AI/Chat/ChatToolRegistry.swift +++ b/TablePro/Core/AI/Chat/ChatToolRegistry.swift @@ -6,27 +6,12 @@ import Foundation import os -/// Process-wide registry of `ChatTool` implementations available to AI chat. @MainActor final class ChatToolRegistry { static let shared = ChatToolRegistry() private static let logger = Logger(subsystem: "com.TablePro", category: "ChatToolRegistry") - private static let readOnlyToolNames: Set = [ - "list_connections", - "get_connection_status", - "list_databases", - "list_schemas", - "list_tables", - "describe_table", - "get_table_ddl" - ] - - private static let editModeToolNames: Set = readOnlyToolNames.union([ - "execute_query" - ]) - private var tools: [String: any ChatTool] = [:] init() {} @@ -48,8 +33,9 @@ final class ChatToolRegistry { } func tool(named name: String, in mode: AIChatMode) -> (any ChatTool)? { - guard Self.isToolAllowed(name: name, in: mode) else { return nil } - return tools[name] + guard let tool = tools[name] else { return nil } + guard tool.mode.isAllowed(in: mode) else { return nil } + return tool } var allTools: [any ChatTool] { @@ -62,25 +48,22 @@ final class ChatToolRegistry { } func allTools(for mode: AIChatMode) -> [any ChatTool] { - allTools.filter { Self.isToolAllowed(name: $0.name, in: mode) } + allTools.filter { $0.mode.isAllowed(in: mode) } } func allSpecs(for mode: AIChatMode) -> [ChatToolSpec] { allTools(for: mode).map(\.spec) } - nonisolated static func requiresApproval(toolName: String) -> Bool { - !readOnlyToolNames.contains(toolName) + func requiresApproval(toolName: String) -> Bool { + guard let tool = tools[toolName] else { return true } + return tool.mode.requiresApproval } - nonisolated static func isToolAllowed(name: String, in mode: AIChatMode) -> Bool { - switch mode { - case .ask: - return readOnlyToolNames.contains(name) - case .edit: - return editModeToolNames.contains(name) - case .agent: - return true + func isToolAllowed(name: String, in mode: AIChatMode) -> Bool { + guard let tool = tools[name] else { + return mode == .agent } + return tool.mode.isAllowed(in: mode) } } diff --git a/TablePro/Core/AI/Chat/ChatToolSchemaBuilder.swift b/TablePro/Core/AI/Chat/ChatToolSchemaBuilder.swift new file mode 100644 index 000000000..4437b1661 --- /dev/null +++ b/TablePro/Core/AI/Chat/ChatToolSchemaBuilder.swift @@ -0,0 +1,58 @@ +// +// ChatToolSchemaBuilder.swift +// TablePro +// + +import Foundation + +enum ChatToolSchemaBuilder { + static func object(properties: [String: JsonValue], required: [String] = []) -> JsonValue { + var fields: [String: JsonValue] = [ + "type": .string("object"), + "properties": .object(properties) + ] + if !required.isEmpty { + fields["required"] = .array(required.map(JsonValue.string)) + } + return .object(fields) + } + + static func string(description: String) -> JsonValue { + .object([ + "type": .string("string"), + "description": .string(description) + ]) + } + + static func enumString(_ values: [String], description: String) -> JsonValue { + .object([ + "type": .string("string"), + "enum": .array(values.map(JsonValue.string)), + "description": .string(description) + ]) + } + + static func boolean(description: String) -> JsonValue { + .object([ + "type": .string("boolean"), + "description": .string(description) + ]) + } + + static func integer(description: String) -> JsonValue { + .object([ + "type": .string("integer"), + "description": .string(description) + ]) + } +} + +extension ChatToolSchemaBuilder { + static var connectionId: JsonValue { + string(description: "UUID of the connection") + } + + static var schemaName: JsonValue { + string(description: "Schema name (uses current if omitted)") + } +} diff --git a/TablePro/Core/AI/Chat/MentionDetector.swift b/TablePro/Core/AI/Chat/MentionDetector.swift index 7c61abbb3..dc1521927 100644 --- a/TablePro/Core/AI/Chat/MentionDetector.swift +++ b/TablePro/Core/AI/Chat/MentionDetector.swift @@ -11,46 +11,48 @@ struct MentionMatch: Equatable, Sendable { } enum MentionDetector { + private static let triggerScalar: Unicode.Scalar = "@" + static func detect(in text: String, caret: Int) -> MentionMatch? { - let nsText = text as NSString - guard caret >= 0, caret <= nsText.length else { return nil } - - var idx = caret - 1 - while idx >= 0 { - let c = nsText.character(at: idx) - if c == 0x40 { - guard isBoundary(before: idx, in: nsText) else { return nil } - let queryStart = idx + 1 - let queryLength = caret - queryStart - let query = nsText.substring(with: NSRange(location: queryStart, length: queryLength)) + guard caret >= 0 else { return nil } + let utf16Length = text.utf16.count + guard caret <= utf16Length else { return nil } + + let caretIndex = String.Index(utf16Offset: caret, in: text) + let scalars = text.unicodeScalars + let scalarStart = scalars.startIndex + let scalarCaret = caretIndex.samePosition(in: scalars) ?? caretIndex + var cursor = scalarCaret + + while cursor > scalarStart { + let previous = scalars.index(before: cursor) + let scalar = scalars[previous] + if scalar == triggerScalar { + guard isBoundary(before: previous, in: scalars) else { return nil } + let triggerOffset = previous.utf16Offset(in: text) + let queryStart = scalars.index(after: previous) + let query = String(scalars[queryStart ..< scalarCaret]) return MentionMatch( - range: NSRange(location: idx, length: caret - idx), + range: NSRange(location: triggerOffset, length: caret - triggerOffset), query: query ) } - if !isQueryCharacter(c) { - return nil - } - idx -= 1 + if !isQueryCharacter(scalar) { return nil } + cursor = previous } return nil } - private static func isQueryCharacter(_ c: unichar) -> Bool { - if c >= 0x41 && c <= 0x5A { return true } - if c >= 0x61 && c <= 0x7A { return true } - if c >= 0x30 && c <= 0x39 { return true } - if c == 0x5F { return true } - guard let scalar = Unicode.Scalar(c) else { return false } + private static func isQueryCharacter(_ scalar: Unicode.Scalar) -> Bool { + if scalar == "_" { return true } + if CharacterSet.alphanumerics.contains(scalar) { return true } return CharacterSet.letters.contains(scalar) } - private static func isBoundary(before idx: Int, in nsText: NSString) -> Bool { - guard idx > 0 else { return true } - let prev = nsText.character(at: idx - 1) - guard let scalar = Unicode.Scalar(prev) else { return true } - if CharacterSet.whitespacesAndNewlines.contains(scalar) { return true } - if CharacterSet.punctuationCharacters.contains(scalar) { return true } - return false + private static func isBoundary(before index: String.UnicodeScalarView.Index, + in scalars: String.UnicodeScalarView) -> Bool { + guard index > scalars.startIndex else { return true } + let scalar = scalars[scalars.index(before: index)] + return !isQueryCharacter(scalar) } } diff --git a/TablePro/Core/AI/Chat/Tools/ConfirmDestructiveOperationChatTool.swift b/TablePro/Core/AI/Chat/Tools/ConfirmDestructiveOperationChatTool.swift index e6398dd1e..c2c081a1f 100644 --- a/TablePro/Core/AI/Chat/Tools/ConfirmDestructiveOperationChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/ConfirmDestructiveOperationChatTool.swift @@ -5,15 +5,7 @@ import Foundation -/// Execute a destructive DDL query (DROP, TRUNCATE, ALTER...DROP) after the AI -/// includes the verbatim confirmation phrase in the arguments. The connection's -/// safe-mode dialog still runs before the query executes, so the user remains -/// the final gate even if the AI mis-uses this tool. struct ConfirmDestructiveOperationChatTool: ChatTool { - /// Intentionally NOT localized: this is a wire-level contract the AI must - /// reproduce verbatim in `confirmation_phrase`. Translating it would change - /// the contract per locale and break model prompts that depend on the - /// English string. static let requiredPhrase = "I understand this is irreversible" let name = "confirm_destructive_operation" @@ -21,31 +13,20 @@ struct ConfirmDestructiveOperationChatTool: ChatTool { Execute a destructive DDL query (DROP, TRUNCATE, ALTER...DROP) after explicit confirmation.\ Pass confirmation_phrase exactly as: I understand this is irreversible """) - let inputSchema: JsonValue = .object([ - "type": .string("object"), - "properties": .object([ - "connection_id": .object([ - "type": .string("string"), - "description": .string("UUID of the active connection") - ]), - "query": .object([ - "type": .string("string"), - "description": .string("The destructive query to execute") - ]), - "confirmation_phrase": .object([ - "type": .string("string"), - "description": .string("Must be exactly: I understand this is irreversible") - ]) - ]), - "required": .array([ - .string("connection_id"), - .string("query"), - .string("confirmation_phrase") - ]) - ]) + let inputSchema: JsonValue = ChatToolSchemaBuilder.object( + properties: [ + "connection_id": ChatToolSchemaBuilder.connectionId, + "query": ChatToolSchemaBuilder.string(description: "The destructive query to execute"), + "confirmation_phrase": ChatToolSchemaBuilder.string( + description: "Must be exactly: I understand this is irreversible" + ) + ], + required: ["connection_id", "query", "confirmation_phrase"] + ) + let mode: ChatToolMode = .agentOnly func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { - let connectionId = try resolveConnectionId(input: input, context: context) + let connectionId = try context.resolveConnectionId(input) let query = try ChatToolArgumentDecoder.requireString(input, key: "query") let confirmationPhrase = try ChatToolArgumentDecoder.requireString(input, key: "confirmation_phrase") diff --git a/TablePro/Core/AI/Chat/Tools/DescribeTableChatTool.swift b/TablePro/Core/AI/Chat/Tools/DescribeTableChatTool.swift index 5e6dc8e5e..2352fdcc1 100644 --- a/TablePro/Core/AI/Chat/Tools/DescribeTableChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/DescribeTableChatTool.swift @@ -8,27 +8,18 @@ import Foundation struct DescribeTableChatTool: ChatTool { let name = "describe_table" let description = String(localized: "Describe the columns of a table or view.") - let inputSchema: JsonValue = .object([ - "type": .string("object"), - "properties": .object([ - "connection_id": .object([ - "type": .string("string"), - "description": .string("UUID of the connection") - ]), - "table": .object([ - "type": .string("string"), - "description": .string("Table or view name") - ]), - "schema": .object([ - "type": .string("string"), - "description": .string("Schema name (uses current if omitted)") - ]) - ]), - "required": .array([.string("table")]) - ]) + let inputSchema: JsonValue = ChatToolSchemaBuilder.object( + properties: [ + "connection_id": ChatToolSchemaBuilder.connectionId, + "table": ChatToolSchemaBuilder.string(description: "Table or view name"), + "schema": ChatToolSchemaBuilder.schemaName + ], + required: ["table"] + ) + let mode: ChatToolMode = .readOnly func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { - let connectionId = try resolveConnectionId(input: input, context: context) + let connectionId = try context.resolveConnectionId(input) let table = try ChatToolArgumentDecoder.requireString(input, key: "table") let schema = ChatToolArgumentDecoder.optionalString(input, key: "schema") let payload = try await context.bridge.describeTable( diff --git a/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift b/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift index 8dff31e88..efb9111d1 100644 --- a/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift @@ -5,10 +5,6 @@ import Foundation -/// Run a SQL query against the active connection. Destructive statements -/// (DROP, TRUNCATE, ALTER...DROP) are rejected here; the AI must use the -/// `confirm_destructive_operation` tool with the explicit confirmation phrase. -/// Write queries trigger the connection's safe-mode dialog flow. struct ExecuteQueryChatTool: ChatTool { let name = "execute_query" let description = String(localized: """ @@ -16,39 +12,25 @@ struct ExecuteQueryChatTool: ChatTool { Multi-statement queries are rejected. Destructive operations (DROP, TRUNCATE, ALTER...DROP)\ are blocked here; use confirm_destructive_operation instead. """) - let inputSchema: JsonValue = .object([ - "type": .string("object"), - "properties": .object([ - "connection_id": .object([ - "type": .string("string"), - "description": .string("UUID of the connection") - ]), - "query": .object([ - "type": .string("string"), - "description": .string("SQL or NoSQL query text") - ]), - "max_rows": .object([ - "type": .string("integer"), - "description": .string("Maximum rows to return (default 500, max 10000)") - ]), - "timeout_seconds": .object([ - "type": .string("integer"), - "description": .string("Query timeout in seconds (default 30, max 300)") - ]), - "database": .object([ - "type": .string("string"), - "description": .string("Switch to this database before executing") - ]), - "schema": .object([ - "type": .string("string"), - "description": .string("Switch to this schema before executing") - ]) - ]), - "required": .array([.string("connection_id"), .string("query")]) - ]) + let inputSchema: JsonValue = ChatToolSchemaBuilder.object( + properties: [ + "connection_id": ChatToolSchemaBuilder.connectionId, + "query": ChatToolSchemaBuilder.string(description: "SQL or NoSQL query text"), + "max_rows": ChatToolSchemaBuilder.integer( + description: "Maximum rows to return (default 500, max 10000)" + ), + "timeout_seconds": ChatToolSchemaBuilder.integer( + description: "Query timeout in seconds (default 30, max 300)" + ), + "database": ChatToolSchemaBuilder.string(description: "Switch to this database before executing"), + "schema": ChatToolSchemaBuilder.string(description: "Switch to this schema before executing") + ], + required: ["connection_id", "query"] + ) + let mode: ChatToolMode = .write func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { - let connectionId = try resolveConnectionId(input: input, context: context) + let connectionId = try context.resolveConnectionId(input) let query = try ChatToolArgumentDecoder.requireString(input, key: "query") let database = ChatToolArgumentDecoder.optionalString(input, key: "database") let schema = ChatToolArgumentDecoder.optionalString(input, key: "schema") @@ -79,9 +61,6 @@ struct ExecuteQueryChatTool: ChatTool { let meta = try await ToolConnectionMetadata.resolve(connectionId: connectionId) - // Classify BEFORE mutating session state. A destructive query asked for - // a database/schema switch should not leave the user on the new context - // when we then refuse to run it. let tier = QueryClassifier.classifyTier(query, databaseType: meta.databaseType) if tier == .destructive { return ChatToolResult( diff --git a/TablePro/Core/AI/Chat/Tools/GetConnectionStatusChatTool.swift b/TablePro/Core/AI/Chat/Tools/GetConnectionStatusChatTool.swift index c6ef2c7c8..ac71531e2 100644 --- a/TablePro/Core/AI/Chat/Tools/GetConnectionStatusChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/GetConnectionStatusChatTool.swift @@ -8,30 +8,17 @@ import Foundation struct GetConnectionStatusChatTool: ChatTool { let name = "get_connection_status" let description = String(localized: "Get detailed status for a specific database connection.") - let inputSchema: JsonValue = .object([ - "type": .string("object"), - "properties": .object([ - "connection_id": .object([ - "type": .string("string"), - "description": .string("UUID of the connection") - ]) - ]), - "required": .array([.string("connection_id")]) - ]) + let inputSchema: JsonValue = ChatToolSchemaBuilder.object( + properties: [ + "connection_id": ChatToolSchemaBuilder.connectionId + ], + required: ["connection_id"] + ) + let mode: ChatToolMode = .readOnly func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { - let connectionId = try resolveConnectionId(input: input, context: context) + let connectionId = try context.resolveConnectionId(input) let payload = try await context.bridge.getConnectionStatus(connectionId: connectionId) return ChatToolResult(content: payload.jsonString(prettyPrinted: true)) } } - -func resolveConnectionId(input: JsonValue, context: ChatToolContext) throws -> UUID { - if let connectionId = try? ChatToolArgumentDecoder.requireUUID(input, key: "connection_id") { - return connectionId - } - if let active = context.connectionId { - return active - } - throw ChatToolArgumentError.missingOrInvalid(key: "connection_id", expected: "UUID string (or attach a connection in the chat)") -} diff --git a/TablePro/Core/AI/Chat/Tools/GetTableDDLChatTool.swift b/TablePro/Core/AI/Chat/Tools/GetTableDDLChatTool.swift index c4f6b0fcf..dc0e06768 100644 --- a/TablePro/Core/AI/Chat/Tools/GetTableDDLChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/GetTableDDLChatTool.swift @@ -8,27 +8,18 @@ import Foundation struct GetTableDDLChatTool: ChatTool { let name = "get_table_ddl" let description = String(localized: "Get the DDL (CREATE statement) for a table.") - let inputSchema: JsonValue = .object([ - "type": .string("object"), - "properties": .object([ - "connection_id": .object([ - "type": .string("string"), - "description": .string("UUID of the connection") - ]), - "table": .object([ - "type": .string("string"), - "description": .string("Table name") - ]), - "schema": .object([ - "type": .string("string"), - "description": .string("Schema name (uses current if omitted)") - ]) - ]), - "required": .array([.string("table")]) - ]) + let inputSchema: JsonValue = ChatToolSchemaBuilder.object( + properties: [ + "connection_id": ChatToolSchemaBuilder.connectionId, + "table": ChatToolSchemaBuilder.string(description: "Table name"), + "schema": ChatToolSchemaBuilder.schemaName + ], + required: ["table"] + ) + let mode: ChatToolMode = .readOnly func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { - let connectionId = try resolveConnectionId(input: input, context: context) + let connectionId = try context.resolveConnectionId(input) let table = try ChatToolArgumentDecoder.requireString(input, key: "table") let schema = ChatToolArgumentDecoder.optionalString(input, key: "schema") let payload = try await context.bridge.getTableDDL( diff --git a/TablePro/Core/AI/Chat/Tools/ListConnectionsChatTool.swift b/TablePro/Core/AI/Chat/Tools/ListConnectionsChatTool.swift index 8de1cab1e..ab02a7dcf 100644 --- a/TablePro/Core/AI/Chat/Tools/ListConnectionsChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/ListConnectionsChatTool.swift @@ -8,10 +8,8 @@ import Foundation struct ListConnectionsChatTool: ChatTool { let name = "list_connections" let description = String(localized: "List all saved database connections with their current status.") - let inputSchema: JsonValue = .object([ - "type": .string("object"), - "properties": .object([:]) - ]) + let inputSchema: JsonValue = ChatToolSchemaBuilder.object(properties: [:]) + let mode: ChatToolMode = .readOnly func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { let payload = await context.bridge.listConnections() diff --git a/TablePro/Core/AI/Chat/Tools/ListDatabasesChatTool.swift b/TablePro/Core/AI/Chat/Tools/ListDatabasesChatTool.swift index 21d20e210..396cdca4c 100644 --- a/TablePro/Core/AI/Chat/Tools/ListDatabasesChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/ListDatabasesChatTool.swift @@ -8,18 +8,15 @@ import Foundation struct ListDatabasesChatTool: ChatTool { let name = "list_databases" let description = String(localized: "List databases available on a connection.") - let inputSchema: JsonValue = .object([ - "type": .string("object"), - "properties": .object([ - "connection_id": .object([ - "type": .string("string"), - "description": .string("UUID of the connection") - ]) - ]) - ]) + let inputSchema: JsonValue = ChatToolSchemaBuilder.object( + properties: [ + "connection_id": ChatToolSchemaBuilder.connectionId + ] + ) + let mode: ChatToolMode = .readOnly func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { - let connectionId = try resolveConnectionId(input: input, context: context) + let connectionId = try context.resolveConnectionId(input) let payload = try await context.bridge.listDatabases(connectionId: connectionId) return ChatToolResult(content: payload.jsonString(prettyPrinted: true)) } diff --git a/TablePro/Core/AI/Chat/Tools/ListSchemasChatTool.swift b/TablePro/Core/AI/Chat/Tools/ListSchemasChatTool.swift index f9e3fc099..b0e326601 100644 --- a/TablePro/Core/AI/Chat/Tools/ListSchemasChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/ListSchemasChatTool.swift @@ -8,18 +8,15 @@ import Foundation struct ListSchemasChatTool: ChatTool { let name = "list_schemas" let description = String(localized: "List schemas available in the active database of a connection.") - let inputSchema: JsonValue = .object([ - "type": .string("object"), - "properties": .object([ - "connection_id": .object([ - "type": .string("string"), - "description": .string("UUID of the connection") - ]) - ]) - ]) + let inputSchema: JsonValue = ChatToolSchemaBuilder.object( + properties: [ + "connection_id": ChatToolSchemaBuilder.connectionId + ] + ) + let mode: ChatToolMode = .readOnly func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { - let connectionId = try resolveConnectionId(input: input, context: context) + let connectionId = try context.resolveConnectionId(input) let payload = try await context.bridge.listSchemas(connectionId: connectionId) return ChatToolResult(content: payload.jsonString(prettyPrinted: true)) } diff --git a/TablePro/Core/AI/Chat/Tools/ListTablesChatTool.swift b/TablePro/Core/AI/Chat/Tools/ListTablesChatTool.swift index 0afb1c942..c6ed8d952 100644 --- a/TablePro/Core/AI/Chat/Tools/ListTablesChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/ListTablesChatTool.swift @@ -8,30 +8,20 @@ import Foundation struct ListTablesChatTool: ChatTool { let name = "list_tables" let description = String(localized: "List tables and views in the active database of a connection.") - let inputSchema: JsonValue = .object([ - "type": .string("object"), - "properties": .object([ - "connection_id": .object([ - "type": .string("string"), - "description": .string("UUID of the connection") - ]), - "database": .object([ - "type": .string("string"), - "description": .string("Database name (uses current if omitted)") - ]), - "schema": .object([ - "type": .string("string"), - "description": .string("Schema name (uses current if omitted)") - ]), - "include_row_counts": .object([ - "type": .string("boolean"), - "description": .string("Include approximate row counts (default false)") - ]) - ]) - ]) + let inputSchema: JsonValue = ChatToolSchemaBuilder.object( + properties: [ + "connection_id": ChatToolSchemaBuilder.connectionId, + "database": ChatToolSchemaBuilder.string(description: "Database name (uses current if omitted)"), + "schema": ChatToolSchemaBuilder.schemaName, + "include_row_counts": ChatToolSchemaBuilder.boolean( + description: "Include approximate row counts (default false)" + ) + ] + ) + let mode: ChatToolMode = .readOnly func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { - let connectionId = try resolveConnectionId(input: input, context: context) + let connectionId = try context.resolveConnectionId(input) let database = ChatToolArgumentDecoder.optionalString(input, key: "database") let schema = ChatToolArgumentDecoder.optionalString(input, key: "schema") let includeRowCounts = ChatToolArgumentDecoder.optionalBool(input, key: "include_row_counts", default: false) diff --git a/TablePro/Core/AI/OllamaDetector.swift b/TablePro/Core/AI/OllamaDetector.swift index ccecd5e08..fa7a27d89 100644 --- a/TablePro/Core/AI/OllamaDetector.swift +++ b/TablePro/Core/AI/OllamaDetector.swift @@ -64,9 +64,11 @@ enum OllamaDetector { } return models.compactMap { $0["name"] as? String }.sorted() + } catch let error as URLError { + logger.debug("Ollama detection: URLError \(error.code.rawValue, privacy: .public) (\(error.localizedDescription, privacy: .public))") + return nil } catch { - // Ollama not running -- expected, not an error - logger.debug("Ollama not detected: \(error.localizedDescription)") + logger.debug("Ollama detection: \(String(describing: type(of: error)), privacy: .public) - \(error.localizedDescription, privacy: .public)") return nil } } diff --git a/TablePro/Core/AI/Registry/AIProviderRegistration.swift b/TablePro/Core/AI/Registry/AIProviderRegistration.swift index dd3ebf143..57f401ecb 100644 --- a/TablePro/Core/AI/Registry/AIProviderRegistration.swift +++ b/TablePro/Core/AI/Registry/AIProviderRegistration.swift @@ -22,6 +22,7 @@ enum AIProviderRegistration { AnthropicProvider( endpoint: config.endpoint, apiKey: apiKey ?? "", + model: config.model, maxOutputTokens: config.maxOutputTokens ?? 4_096 ) } diff --git a/TablePro/ViewModels/AIChatViewModel+Streaming.swift b/TablePro/ViewModels/AIChatViewModel+Streaming.swift index b81ef6430..2c9642f45 100644 --- a/TablePro/ViewModels/AIChatViewModel+Streaming.swift +++ b/TablePro/ViewModels/AIChatViewModel+Streaming.swift @@ -23,6 +23,12 @@ extension AIChatViewModel { let cancelled: Bool } + private enum ToolResolution { + case blocked + case missing + case resolved(any ChatTool) + } + func startStreaming() { guard case .idle = streamingState else { return } @@ -426,7 +432,19 @@ extension AIChatViewModel { if Task.isCancelled { return ToolResultBlock(toolUseId: block.id, content: "Cancelled", isError: true) } - guard ChatToolRegistry.isToolAllowed(name: block.name, in: mode) else { + let resolution = await MainActor.run { () -> ToolResolution in + let activeRegistry = registry ?? ChatToolRegistry.shared + guard activeRegistry.isToolAllowed(name: block.name, in: mode) else { + return .blocked + } + guard let tool = activeRegistry.tool(named: block.name, in: mode) else { + return .missing + } + return .resolved(tool) + } + let tool: any ChatTool + switch resolution { + case .blocked: AIChatViewModel.logger.warning( "Tool '\(block.name, privacy: .public)' blocked in \(mode.rawValue, privacy: .public) mode" ) @@ -435,17 +453,15 @@ extension AIChatViewModel { content: "Tool '\(block.name)' is not available in \(mode.displayName) mode", isError: true ) - } - let tool = await MainActor.run { - (registry ?? ChatToolRegistry.shared).tool(named: block.name, in: mode) - } - guard let tool else { + case .missing: AIChatViewModel.logger.warning("Tool '\(block.name, privacy: .public)' not registered; returning error") return ToolResultBlock( toolUseId: block.id, content: "Tool '\(block.name)' is not available", isError: true ) + case .resolved(let resolved): + tool = resolved } do { let result = try await tool.execute(input: block.input, context: context) diff --git a/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift b/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift index f68fbe2ac..88ab67dde 100644 --- a/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift +++ b/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift @@ -68,7 +68,7 @@ extension AIChatViewModel { @MainActor func computeInitialApprovalState(for toolName: String) -> ToolApprovalState { - if !ChatToolRegistry.requiresApproval(toolName: toolName) { + if !ChatToolRegistry.shared.requiresApproval(toolName: toolName) { return .approved } if let connection, connection.aiAlwaysAllowedTools.contains(toolName) { @@ -166,7 +166,7 @@ extension AIChatViewModel { let result: ChatToolResult switch finalState { case .approved: - guard ChatToolRegistry.isToolAllowed(name: block.name, in: mode) else { + guard ChatToolRegistry.shared.isToolAllowed(name: block.name, in: mode) else { result = ChatToolResult( content: "Tool '\(block.name)' is not available in \(mode.displayName) mode", isError: true diff --git a/TablePro/Views/AIChat/AIChatContextChipView.swift b/TablePro/Views/AIChat/AIChatContextChipView.swift index 61cafa7c4..9c76d6079 100644 --- a/TablePro/Views/AIChat/AIChatContextChipView.swift +++ b/TablePro/Views/AIChat/AIChatContextChipView.swift @@ -29,11 +29,11 @@ struct AIChatContextChipView: View { } } .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Color.accentColor.opacity(0.12), in: Capsule()) + .padding(.vertical, 4) + .background(.tint.opacity(0.08), in: Capsule()) .overlay( Capsule() - .stroke(Color.accentColor.opacity(0.25), lineWidth: 1) + .stroke(.tint.opacity(0.2), lineWidth: 0.5) ) } } diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index a743f66ed..b29d0b127 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -11,6 +11,8 @@ import SwiftUI /// Displays a single AI chat message with appropriate styling struct AIChatMessageView: View { + private static let userBubbleTintOpacity: Double = 0.08 + let message: ChatTurn var onRetry: (() -> Void)? var onRegenerate: (() -> Void)? @@ -24,7 +26,7 @@ struct AIChatMessageView: View { } var body: some View { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 4) { if message.role == .user { // User: timestamp header, then message text in tinted bubble VStack(alignment: .leading, spacing: 4) { @@ -58,11 +60,12 @@ struct AIChatMessageView: View { .buttonStyle(.plain) .foregroundStyle(.tertiary) .help(String(localized: "Edit message")) + .accessibilityLabel(String(localized: "Edit message")) } } } .padding(8) - .background(Color.accentColor.opacity(0.06)) + .background(Color.accentColor.opacity(Self.userBubbleTintOpacity)) .clipShape(RoundedRectangle(cornerRadius: 8)) } else { // Assistant: role header above content @@ -188,7 +191,7 @@ struct AIChatMessageView: View { extension MarkdownUI.Theme { static let tableProChat = MarkdownUI.Theme() .text { - FontSize(13) + FontSize(.em(1.0)) } .code { FontFamilyVariant(.monospaced) @@ -201,7 +204,7 @@ extension MarkdownUI.Theme { .markdownMargin(top: 12, bottom: 4) .markdownTextStyle { FontWeight(.bold) - FontSize(17) + FontSize(.em(1.5)) } } .heading2 { configuration in @@ -209,7 +212,7 @@ extension MarkdownUI.Theme { .markdownMargin(top: 10, bottom: 4) .markdownTextStyle { FontWeight(.semibold) - FontSize(15) + FontSize(.em(1.3)) } } .heading3 { configuration in @@ -217,7 +220,7 @@ extension MarkdownUI.Theme { .markdownMargin(top: 8, bottom: 4) .markdownTextStyle { FontWeight(.bold) - FontSize(13) + FontSize(.em(1.15)) } } .blockquote { configuration in @@ -228,7 +231,7 @@ extension MarkdownUI.Theme { configuration.label .markdownTextStyle { ForegroundColor(.secondary) - FontSize(13) + FontSize(.em(1.0)) } .padding(Edge.Set.leading, 8) } diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 8b2a03292..3d7d88fac 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -9,6 +9,8 @@ import SwiftUI /// AI chat panel displayed alongside the main editor content struct AIChatPanelView: View { + private static let warningBackgroundOpacity: Double = 0.1 + let connection: DatabaseConnection var currentQuery: String? var queryResults: String? @@ -174,6 +176,7 @@ struct AIChatPanelView: View { .padding(.bottom, 8) .transition(.opacity) .animation(.easeInOut(duration: 0.2), value: isUserScrolledUp) + .accessibilityLabel(String(localized: "Scroll to latest message")) } } } @@ -202,7 +205,7 @@ struct AIChatPanelView: View { } .padding(.horizontal, 12) .padding(.vertical, 6) - .background(Color(nsColor: .systemYellow).opacity(0.1)) + .background(Color(nsColor: .systemYellow).opacity(Self.warningBackgroundOpacity)) } // MARK: - Input Area @@ -292,6 +295,7 @@ struct AIChatPanelView: View { } .buttonStyle(.plain) .help(String(localized: "Stop Generating")) + .accessibilityLabel(String(localized: "Stop Generating")) } else { let isEmpty = viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty Button { @@ -304,6 +308,7 @@ struct AIChatPanelView: View { .buttonStyle(.plain) .disabled(isEmpty) .help(String(localized: "Send Message")) + .accessibilityLabel(String(localized: "Send Message")) } } @@ -397,6 +402,7 @@ struct AIChatPanelView: View { .menuStyle(.borderlessButton) .fixedSize() .help(String(localized: "Attach context")) + .accessibilityLabel(String(localized: "Attach context")) } } @@ -436,6 +442,7 @@ struct AIChatPanelView: View { .menuStyle(.borderlessButton) .fixedSize() .help(String(localized: "Slash commands")) + .accessibilityLabel(String(localized: "Slash commands")) } @ViewBuilder diff --git a/TablePro/Views/AIChat/AIChatToolResultBlockView.swift b/TablePro/Views/AIChat/AIChatToolResultBlockView.swift index e12a545af..ef93e9d92 100644 --- a/TablePro/Views/AIChat/AIChatToolResultBlockView.swift +++ b/TablePro/Views/AIChat/AIChatToolResultBlockView.swift @@ -31,13 +31,10 @@ struct AIChatToolResultBlockView: View { } .padding(.horizontal, 8) .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(accentColor.opacity(0.12)) - ) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) .contentShape(RoundedRectangle(cornerRadius: 6)) } - .buttonStyle(.plain) + .buttonStyle(.borderless) if isExpanded { ScrollView(.horizontal, showsIndicators: false) { diff --git a/TablePro/Views/AIChat/AIChatToolUseBlockView.swift b/TablePro/Views/AIChat/AIChatToolUseBlockView.swift index 7d02e6f74..f300e8011 100644 --- a/TablePro/Views/AIChat/AIChatToolUseBlockView.swift +++ b/TablePro/Views/AIChat/AIChatToolUseBlockView.swift @@ -47,13 +47,10 @@ struct AIChatToolUseBlockView: View { } .padding(.horizontal, 8) .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .quaternaryLabelColor).opacity(0.5)) - ) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) .contentShape(RoundedRectangle(cornerRadius: 6)) } - .buttonStyle(.plain) + .buttonStyle(.borderless) if shouldShowInput { ScrollView(.horizontal, showsIndicators: false) { diff --git a/TablePro/Views/AIChat/MentionSuggestionListView.swift b/TablePro/Views/AIChat/MentionSuggestionListView.swift index 6c823f0ce..4c708bcb1 100644 --- a/TablePro/Views/AIChat/MentionSuggestionListView.swift +++ b/TablePro/Views/AIChat/MentionSuggestionListView.swift @@ -32,26 +32,34 @@ private struct MentionRowView: View { let candidate: MentionCandidate let isSelected: Bool + private var primaryTextColor: Color { + Color(nsColor: .alternateSelectedControlTextColor) + } + + private var secondaryTextColor: Color { + Color(nsColor: .alternateSelectedControlTextColor).opacity(0.85) + } + var body: some View { HStack(spacing: 6) { Image(systemName: candidate.symbolName) .frame(width: 14, alignment: .center) - .foregroundStyle(isSelected ? Color.white : .secondary) + .foregroundStyle(isSelected ? primaryTextColor : .secondary) Text(candidate.displayLabel) .lineLimit(1) .truncationMode(.middle) - .foregroundStyle(isSelected ? Color.white : .primary) + .foregroundStyle(isSelected ? primaryTextColor : .primary) Spacer(minLength: 4) if let secondary = candidate.secondaryLabel { Text(secondary) .font(.caption2) .lineLimit(1) - .foregroundStyle(isSelected ? Color.white.opacity(0.85) : .secondary) + .foregroundStyle(isSelected ? secondaryTextColor : .secondary) } } .font(.callout) .padding(.horizontal, 10) - .padding(.vertical, 3) + .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) .background( isSelected diff --git a/TableProTests/Core/AI/ChatToolRegistryModeTests.swift b/TableProTests/Core/AI/ChatToolRegistryModeTests.swift index 4f24b32de..e5ac4d58e 100644 --- a/TableProTests/Core/AI/ChatToolRegistryModeTests.swift +++ b/TableProTests/Core/AI/ChatToolRegistryModeTests.swift @@ -14,6 +14,7 @@ struct ChatToolRegistryModeTests { let name: String let description = "" let inputSchema: JsonValue = .object(["type": .string("object")]) + let mode: ChatToolMode func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { ChatToolResult(content: "ok") @@ -33,10 +34,10 @@ struct ChatToolRegistryModeTests { private static func makeRegistryWithAllTools() -> ChatToolRegistry { let registry = ChatToolRegistry() for name in readOnlyToolNames { - registry.register(StubTool(name: name)) + registry.register(StubTool(name: name, mode: .readOnly)) } - registry.register(StubTool(name: "execute_query")) - registry.register(StubTool(name: "confirm_destructive_operation")) + registry.register(StubTool(name: "execute_query", mode: .write)) + registry.register(StubTool(name: "confirm_destructive_operation", mode: .agentOnly)) return registry } @@ -74,7 +75,7 @@ struct ChatToolRegistryModeTests { for mode in AIChatMode.allCases { let allowedFromSpecs = Set(registry.allSpecs(for: mode).map(\.name)) for tool in registry.allTools { - let allowed = ChatToolRegistry.isToolAllowed(name: tool.name, in: mode) + let allowed = registry.isToolAllowed(name: tool.name, in: mode) #expect(allowed == allowedFromSpecs.contains(tool.name)) } } @@ -92,8 +93,18 @@ struct ChatToolRegistryModeTests { @Test("Unknown tool names are not allowed in any mode except agent") func unknownToolsBlockedOutsideAgent() { - #expect(ChatToolRegistry.isToolAllowed(name: "future_tool", in: .ask) == false) - #expect(ChatToolRegistry.isToolAllowed(name: "future_tool", in: .edit) == false) - #expect(ChatToolRegistry.isToolAllowed(name: "future_tool", in: .agent) == true) + let registry = ChatToolRegistry() + #expect(registry.isToolAllowed(name: "future_tool", in: .ask) == false) + #expect(registry.isToolAllowed(name: "future_tool", in: .edit) == false) + #expect(registry.isToolAllowed(name: "future_tool", in: .agent) == true) + } + + @Test("requiresApproval reflects the registered tool mode") + func requiresApprovalUsesMode() { + let registry = Self.makeRegistryWithAllTools() + #expect(registry.requiresApproval(toolName: "list_tables") == false) + #expect(registry.requiresApproval(toolName: "execute_query") == true) + #expect(registry.requiresApproval(toolName: "confirm_destructive_operation") == true) + #expect(registry.requiresApproval(toolName: "unknown_tool") == true) } } diff --git a/TableProTests/Core/AI/ChatToolRegistryTests.swift b/TableProTests/Core/AI/ChatToolRegistryTests.swift index 30616114c..e45ead2ca 100644 --- a/TableProTests/Core/AI/ChatToolRegistryTests.swift +++ b/TableProTests/Core/AI/ChatToolRegistryTests.swift @@ -14,12 +14,14 @@ struct ChatToolRegistryTests { let name: String let description: String let inputSchema: JsonValue + let mode: ChatToolMode let response: String - init(name: String, description: String = "", response: String = "ok") { + init(name: String, description: String = "", mode: ChatToolMode = .readOnly, response: String = "ok") { self.name = name self.description = description self.inputSchema = .object(["type": .string("object"), "properties": .object([:])]) + self.mode = mode self.response = response } diff --git a/TableProTests/Core/AI/ExecuteToolUsesTests.swift b/TableProTests/Core/AI/ExecuteToolUsesTests.swift index 23f425b9b..e86013ad0 100644 --- a/TableProTests/Core/AI/ExecuteToolUsesTests.swift +++ b/TableProTests/Core/AI/ExecuteToolUsesTests.swift @@ -16,14 +16,16 @@ struct ExecuteToolUsesTests { let name: String let description: String let inputSchema: JsonValue + let mode: ChatToolMode let response: String let isError: Bool private(set) var invocations: [JsonValue] = [] - init(name: String, response: String = "ok", isError: Bool = false) { + init(name: String, response: String = "ok", isError: Bool = false, mode: ChatToolMode = .readOnly) { self.name = name self.description = "" self.inputSchema = .object(["type": .string("object")]) + self.mode = mode self.response = response self.isError = isError } @@ -34,12 +36,11 @@ struct ExecuteToolUsesTests { } } - /// Tool that always throws when called. Used to verify the error path - /// returns a ToolResultBlock with isError: true rather than crashing. private struct ThrowingTool: ChatTool { let name: String let description = "" let inputSchema: JsonValue = .object(["type": .string("object")]) + let mode: ChatToolMode = .readOnly struct Boom: Error {} func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { throw Boom() @@ -189,7 +190,7 @@ struct ExecuteToolUsesTests { @Test("execute_query blocked in Ask mode returns isError result without invoking tool") func askModeBlocksExecuteQuery() async { let registry = ChatToolRegistry() - let stub = StubTool(name: "execute_query", response: "should-not-run") + let stub = StubTool(name: "execute_query", response: "should-not-run", mode: .write) registry.register(stub) let blocks = [ToolUseBlock(id: "u1", name: "execute_query", input: .object([:]))] let results = await AIChatViewModel.executeToolUses( @@ -206,7 +207,7 @@ struct ExecuteToolUsesTests { @Test("confirm_destructive_operation blocked in Edit mode returns isError result") func editModeBlocksDestructiveConfirm() async { let registry = ChatToolRegistry() - let stub = StubTool(name: "confirm_destructive_operation", response: "should-not-run") + let stub = StubTool(name: "confirm_destructive_operation", response: "should-not-run", mode: .agentOnly) registry.register(stub) let blocks = [ToolUseBlock(id: "u1", name: "confirm_destructive_operation", input: .object([:]))] let results = await AIChatViewModel.executeToolUses( diff --git a/TableProTests/Core/AI/MentionDetectorTests.swift b/TableProTests/Core/AI/MentionDetectorTests.swift index 6607b9aec..564f17fff 100644 --- a/TableProTests/Core/AI/MentionDetectorTests.swift +++ b/TableProTests/Core/AI/MentionDetectorTests.swift @@ -82,4 +82,28 @@ struct MentionDetectorTests { let match = MentionDetector.detect(in: "@niño", caret: 5) #expect(match?.query == "niño") } + + @Test("Emoji directly before @ is treated as a boundary, no surrogate confusion") + func emojiBoundaryBeforeTrigger() { + let text = "hi 😀@tab" + let caret = (text as NSString).length + let match = MentionDetector.detect(in: text, caret: caret) + let triggerLocation = (text as NSString).range(of: "@").location + #expect(match?.query == "tab") + #expect(match?.range == NSRange(location: triggerLocation, length: caret - triggerLocation)) + } + + @Test("Emoji inside the query token does not break detection") + func emojiInsideQueryStops() { + let text = "@🚀" + let caret = (text as NSString).length + #expect(MentionDetector.detect(in: text, caret: caret) == nil) + } + + @Test("Caret right after a non-BMP scalar with no @ returns nil") + func nonBmpAfterCaretWithoutTrigger() { + let text = "hello 😀" + let caret = (text as NSString).length + #expect(MentionDetector.detect(in: text, caret: caret) == nil) + } }