diff --git a/AGENTS.md b/AGENTS.md index 37862e0f..f7a54bd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,10 @@ # Agent Instructions -Desktop Agent Harness. Swift 6.4+, Xcode 27, macOS 27. You must follow these instructions specifically. +You are an Apple Swift and SwiftUI expoert building a Desktop Agent Harness using Swift 6.4+, Xcode 27, macOS 27. You must follow these instructions specifically. ## Before changing code +- You are kind, slow and methodical. You do not rush. - Read the files on the code path you are changing. Do not guess. - Check `Info.plist` and app configuration before assuming a code bug. - When fixing issues do not assume. Make an assertion about where the problem is, confirm your assertion is true, and then fix the issue there. diff --git a/packages/DBRepository/Sources/DBRepository/DBRepositoryMessaging.swift b/packages/DBRepository/Sources/DBRepository/DBRepositoryMessaging.swift index 2cf7415e..1074eb13 100644 --- a/packages/DBRepository/Sources/DBRepository/DBRepositoryMessaging.swift +++ b/packages/DBRepository/Sources/DBRepository/DBRepositoryMessaging.swift @@ -231,6 +231,52 @@ public extension DBRepository { } } + /// Inbound messages that have not yet been claimed for an agent turn. + func listUnclaimedInboundMessagingMessages(limit: Int = 40) throws -> [MessagingPersistResult] { + let pageSize = max(1, min(limit, 80)) + return try withDatabaseHandle { handle in + let sql = """ + SELECT + m.id, m.thread_id, m.vendor_message_id, m.direction, m.sender, m.body, m.created_at, + m.parent_vendor_message_id, m.reply_count, + t.id, t.plugin_id, t.vendor_thread_id, t.title, t.last_activity_at, t.muted, + t.unread_count, t.created_at + FROM messaging_messages m + INNER JOIN messaging_threads t ON t.id = m.thread_id + LEFT JOIN messaging_agent_handled h + ON h.plugin_id = t.plugin_id + AND h.vendor_message_id = m.vendor_message_id + WHERE m.direction = \(quoted(MessagingMessageDirection.inbound.rawValue)) + AND m.vendor_message_id IS NOT NULL + AND TRIM(m.vendor_message_id) != '' + AND h.plugin_id IS NULL + ORDER BY m.created_at DESC, m.id DESC + LIMIT \(pageSize); + """ + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw Self.sqliteError(handle: handle, fallback: "Failed to prepare unclaimed inbound messaging list.") + } + defer { sqlite3_finalize(statement) } + var rows: [MessagingPersistResult] = [] + while sqlite3_step(statement) == SQLITE_ROW { + let message = try decodeMessagingMessage(statement: statement) + let thread = MessagingThreadDTO( + id: try columnString(statement, index: 9), + pluginID: try columnString(statement, index: 10), + vendorThreadID: try columnString(statement, index: 11), + title: try columnString(statement, index: 12), + lastActivityAt: Self.iso8601Formatter().date(from: try columnString(statement, index: 13)) ?? .now, + muted: sqlite3_column_int(statement, 14) != 0, + unreadCount: Int(sqlite3_column_int(statement, 15)), + createdAt: Self.iso8601Formatter().date(from: try columnString(statement, index: 16)) ?? .now + ) + rows.append(MessagingPersistResult(inserted: true, message: message, thread: thread)) + } + return rows + } + } + /// Latest reply body for each parent, for the channel "N replies" row. func latestReplyPreviews( threadID: String, diff --git a/packages/DBRepository/Sources/DBRepository/DBRepositoryMessagingAgentHandled.swift b/packages/DBRepository/Sources/DBRepository/DBRepositoryMessagingAgentHandled.swift index 4284dae0..3456ed9f 100644 --- a/packages/DBRepository/Sources/DBRepository/DBRepositoryMessagingAgentHandled.swift +++ b/packages/DBRepository/Sources/DBRepository/DBRepositoryMessagingAgentHandled.swift @@ -39,4 +39,45 @@ public extension DBRepository { } } } + + func releaseMessagingAgentHandling(pluginID: String, vendorMessageID: String) throws { + let trimmedPluginID = pluginID.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedMessageID = vendorMessageID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPluginID.isEmpty, !trimmedMessageID.isEmpty else { return } + try withDatabaseHandle { handle in + try Self.execute(""" + DELETE FROM messaging_agent_handled + WHERE plugin_id = \(quoted(trimmedPluginID)) + AND vendor_message_id = \(quoted(trimmedMessageID)); + """, on: handle) + } + } + + /// Lets `$profile` inbound retry when a turn was claimed but never posted a reply. + func releaseUnansweredProfileTokenClaims() throws { + try withDatabaseHandle { handle in + try Self.execute(""" + DELETE FROM messaging_agent_handled + WHERE rowid IN ( + SELECT h.rowid + FROM messaging_agent_handled h + INNER JOIN messaging_threads t ON t.plugin_id = h.plugin_id + INNER JOIN messaging_messages m + ON m.thread_id = t.id + AND m.vendor_message_id = h.vendor_message_id + WHERE m.direction = \(quoted(MessagingMessageDirection.inbound.rawValue)) + AND ( + TRIM(m.body) LIKE '$%' + OR m.body LIKE '%$%' + ) + AND NOT EXISTS ( + SELECT 1 FROM messaging_messages o + WHERE o.thread_id = m.thread_id + AND o.direction = \(quoted(MessagingMessageDirection.outbound.rawValue)) + AND o.created_at >= m.created_at + ) + ); + """, on: handle) + } + } } diff --git a/packages/DBRepository/Tests/DBRepositoryTests/DBMessagingAgentHandledTests.swift b/packages/DBRepository/Tests/DBRepositoryTests/DBMessagingAgentHandledTests.swift index ed51c992..8f064cbb 100644 --- a/packages/DBRepository/Tests/DBRepositoryTests/DBMessagingAgentHandledTests.swift +++ b/packages/DBRepository/Tests/DBRepositoryTests/DBMessagingAgentHandledTests.swift @@ -19,6 +19,93 @@ final class DBMessagingAgentHandledTests: XCTestCase { XCTAssertFalse(secondClaim) } + func testListUnclaimedInboundExcludesClaimedRows() async throws { + let repository = try makeRepository() + _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") + try await repository.upsertMessagingConnector( + MessagingConnectorDTO(pluginID: "slack-connection", displayName: "Slack") + ) + _ = try await repository.persistMessagingInbound( + MessagingInboundRecord( + pluginID: "slack-connection", + vendorThreadID: "C123", + threadTitle: "#general", + vendorMessageID: "1710000002.000200", + sender: "U07FKG8DV19", + body: "$orchestrator tell me about yourself" + ) + ) + _ = try await repository.persistMessagingInbound( + MessagingInboundRecord( + pluginID: "slack-connection", + vendorThreadID: "C123", + threadTitle: "#general", + vendorMessageID: "1710000002.000201", + sender: "U07FKG8DV19", + body: "hello without mention" + ) + ) + + let unclaimed = try await repository.listUnclaimedInboundMessagingMessages() + XCTAssertEqual(Set(unclaimed.compactMap(\.message.vendorMessageID)), [ + "1710000002.000200", + "1710000002.000201" + ]) + + let claimed = try await repository.claimMessagingAgentHandling( + pluginID: "slack-connection", + vendorMessageID: "1710000002.000200" + ) + XCTAssertTrue(claimed) + let remaining = try await repository.listUnclaimedInboundMessagingMessages() + XCTAssertEqual(remaining.compactMap(\.message.vendorMessageID), ["1710000002.000201"]) + } + + func testReleaseUnansweredProfileTokenClaims() async throws { + let repository = try makeRepository() + _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") + try await repository.upsertMessagingConnector( + MessagingConnectorDTO(pluginID: "slack-connection", displayName: "Slack") + ) + _ = try await repository.persistMessagingInbound( + MessagingInboundRecord( + pluginID: "slack-connection", + vendorThreadID: "C123", + threadTitle: "#general", + vendorMessageID: "1710000002.000200", + sender: "U07FKG8DV19", + body: "$developer tell me about yourself" + ) + ) + let claimed = try await repository.claimMessagingAgentHandling( + pluginID: "slack-connection", + vendorMessageID: "1710000002.000200" + ) + XCTAssertTrue(claimed) + try await repository.releaseUnansweredProfileTokenClaims() + let unclaimed = try await repository.listUnclaimedInboundMessagingMessages() + XCTAssertEqual(unclaimed.compactMap(\.message.vendorMessageID), ["1710000002.000200"]) + } + + func testReleaseMessagingAgentHandlingAllowsRetry() async throws { + let repository = try makeRepository() + _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") + let first = try await repository.claimMessagingAgentHandling( + pluginID: "slack-connection", + vendorMessageID: "171.9" + ) + XCTAssertTrue(first) + try await repository.releaseMessagingAgentHandling( + pluginID: "slack-connection", + vendorMessageID: "171.9" + ) + let retry = try await repository.claimMessagingAgentHandling( + pluginID: "slack-connection", + vendorMessageID: "171.9" + ) + XCTAssertTrue(retry) + } + private func makeRepository() throws -> DBRepository { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/packages/DerrickBackend/Sources/DerrickBackend/ConnectorMessagingCommandService.swift b/packages/DerrickBackend/Sources/DerrickBackend/ConnectorMessagingCommandService.swift index e933b714..7e002b98 100644 --- a/packages/DerrickBackend/Sources/DerrickBackend/ConnectorMessagingCommandService.swift +++ b/packages/DerrickBackend/Sources/DerrickBackend/ConnectorMessagingCommandService.swift @@ -102,6 +102,8 @@ public actor ConnectorMessagingCommandService { parentVendorMessageID: request.parentVendorMessageID, repository: repository ) + // Route on a detached task. Awaiting processInbound here deadlocks: + // the turn client sends through this same actor. case .send: guard let vendorThreadID = request.vendorThreadID?.trimmingCharacters(in: .whitespacesAndNewlines), !vendorThreadID.isEmpty, @@ -129,6 +131,9 @@ public actor ConnectorMessagingCommandService { request: request ) DerrickMessagingInboundSignal.postRefresh() + if request.kind == .pollInbox { + DerrickMessagingIngressSignal.postPoll() + } } catch { finish(request.operationID, status: .failed, error: error.localizedDescription) await log( diff --git a/packages/DerrickBackend/Sources/DerrickBackend/MessagingAgentIngressRouter.swift b/packages/DerrickBackend/Sources/DerrickBackend/MessagingAgentIngressRouter.swift index b474d9d1..64501bb4 100644 --- a/packages/DerrickBackend/Sources/DerrickBackend/MessagingAgentIngressRouter.swift +++ b/packages/DerrickBackend/Sources/DerrickBackend/MessagingAgentIngressRouter.swift @@ -1,6 +1,5 @@ import DBRepository import Foundation -import Plugin import Structure /// Routes newly persisted inbound connector messages to agent profiles when the bot is mentioned. @@ -10,11 +9,29 @@ public enum MessagingAgentIngressRouter: Sendable { repository: DBRepository ) async { guard let routeHandler = InProcessServiceBridges.messagingAgentRoute else { + fputs("[MessagingAgentIngressRouter] no route handler installed — inbound agent turns are skipped\n", stderr) return } - for row in rows where row.inserted && row.message.direction == .inbound { + for row in rows where row.message.direction == .inbound { guard let route = await routeCandidate(from: row, repository: repository) else { + let body = row.message.body.trimmingCharacters(in: .whitespacesAndNewlines) + if AgentProfileTokenParser.parse(message: body).handle != nil { + fputs( + "[MessagingAgentIngressRouter] skipped \(body.prefix(80)) pluginID=\(row.thread.pluginID)\n", + stderr + ) + } + let parsedHandle = AgentProfileTokenParser.parse(message: body).handle + if parsedHandle == nil, + let vendorMessageID = row.message.vendorMessageID? + .trimmingCharacters(in: .whitespacesAndNewlines), + !vendorMessageID.isEmpty { + _ = try? await repository.claimMessagingAgentHandling( + pluginID: row.thread.pluginID, + vendorMessageID: vendorMessageID + ) + } continue } do { @@ -23,12 +40,20 @@ public enum MessagingAgentIngressRouter: Sendable { vendorMessageID: route.inboundVendorMessageID ) guard claimed else { continue } + fputs( + "[MessagingAgentIngressRouter] routing pluginID=\(route.pluginID) profile=\(route.profileHandle) message=\(route.inboundVendorMessageID)\n", + stderr + ) try await routeHandler(route) } catch { fputs( "[MessagingAgentIngressRouter] route failed pluginID=\(route.pluginID) message=\(route.inboundVendorMessageID): \(error.localizedDescription)\n", stderr ) + try? await repository.releaseMessagingAgentHandling( + pluginID: route.pluginID, + vendorMessageID: route.inboundVendorMessageID + ) await ServiceLogRecorder.shared.record( service: "messaging", level: .error, @@ -49,61 +74,59 @@ public enum MessagingAgentIngressRouter: Sendable { guard !body.isEmpty else { return nil } guard !ConnectorMentionParser.isAutomatedOutboundEcho(body: body) else { return nil } - let manifestJSON = (try? await repository.listLatestPluginFactoryManifests() - .first(where: { $0.pluginID == row.thread.pluginID })? - .manifestJSON) ?? "" - guard supportsBotMentionRouting(manifestJSON: manifestJSON) else { return nil } - - guard let botUserID = await SlackBotIdentityResolver.Cache.shared + let botUserID = await SlackBotIdentityResolver.Cache.shared .botUserID(pluginID: row.thread.pluginID) - else { + ?? "" + if !botUserID.isEmpty, + message.sender.trimmingCharacters(in: .whitespacesAndNewlines) == botUserID { return nil } - if message.sender.trimmingCharacters(in: .whitespacesAndNewlines) == botUserID { + guard let vendorMessageID = message.vendorMessageID? + .trimmingCharacters(in: .whitespacesAndNewlines), + !vendorMessageID.isEmpty + else { return nil } + let threadParent = ConnectorMentionParser.agentReplyThreadParentVendorMessageID( + inboundVendorMessageID: vendorMessageID, + existingParentVendorMessageID: message.parentVendorMessageID + ) + var continuation: String? + if message.isReply { + let threadMessages = (try? await repository.listMessagingMessages( + threadID: row.thread.id, + limit: MessagingViewport.maxVisibleMessages, + filter: .replyThread(parentVendorMessageID: threadParent) + )) ?? [] + continuation = ConnectorMentionParser.continuationProfileHandle( + in: threadMessages, + excludingVendorMessageID: vendorMessageID + ) + } + guard let resolved = ConnectorMentionParser.resolvePrompt( body: body, botUserID: botUserID, + continuationProfileHandle: continuation, channelDefaultProfileHandle: row.thread.defaultAgentProfileHandle, profileCatalog: (try? await profileCatalog(repository: repository)) ?? [] ) else { return nil } - guard let vendorMessageID = message.vendorMessageID? - .trimmingCharacters(in: .whitespacesAndNewlines), - !vendorMessageID.isEmpty - else { - return nil - } - return MessagingAgentRoute( pluginID: row.thread.pluginID, threadID: row.thread.id, vendorThreadID: row.thread.vendorThreadID, - parentVendorMessageID: message.parentVendorMessageID, + parentVendorMessageID: threadParent, inboundVendorMessageID: vendorMessageID, profileHandle: resolved.profileHandle, prompt: resolved.prompt ) } - private static func supportsBotMentionRouting(manifestJSON: String) -> Bool { - guard let data = manifestJSON.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let extensions = object["extensions"] as? [String: Any], - let derrick = extensions["app.derrick"] as? [String: Any] - else { - return false - } - let role = (derrick["role"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let authScheme = (derrick["auth_scheme"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return role == "connector" && authScheme == "bot_token" - } - private static func profileCatalog(repository: DBRepository) async throws -> [AgentProfileCatalogEntry] { try await repository.listAgentProfiles() .filter(\.isEnabled) diff --git a/packages/DerrickBackend/Sources/DerrickBackend/MessagingInboundNotifier.swift b/packages/DerrickBackend/Sources/DerrickBackend/MessagingInboundNotifier.swift index 1dc140f0..f55df821 100644 --- a/packages/DerrickBackend/Sources/DerrickBackend/MessagingInboundNotifier.swift +++ b/packages/DerrickBackend/Sources/DerrickBackend/MessagingInboundNotifier.swift @@ -10,7 +10,8 @@ public enum MessagingInboundNotifier: Sendable { ) async { _ = uiIsInteractive let enriched = await enrichDisplayNames(rows) - for request in notificationRequests(from: enriched) { + let suppressedPluginID = DerrickMessagingForegroundPresence.pluginIDForSuppressedOSNotifications() + for request in notificationRequests(from: enriched, suppressingPluginID: suppressedPluginID) { do { try await NotificationSender.post(request) } catch { @@ -22,12 +23,19 @@ public enum MessagingInboundNotifier: Sendable { } } - static func notificationRequests(from rows: [MessagingPersistResult]) -> [UserNotificationRequest] { + static func notificationRequests( + from rows: [MessagingPersistResult], + suppressingPluginID: String? = nil + ) -> [UserNotificationRequest] { let inbound = rows.filter { $0.inserted && $0.message.direction == .inbound && !$0.thread.muted } - let replies = inbound.filter(\.message.isReply) - let roots = inbound.filter { !$0.message.isReply } + let suppressed = suppressingPluginID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let notifiable = inbound.filter { row in + suppressed.isEmpty || row.thread.pluginID != suppressed + } + let replies = notifiable.filter(\.message.isReply) + let roots = notifiable.filter { !$0.message.isReply } var requests: [UserNotificationRequest] = [] let rootGrouped = Dictionary(grouping: roots, by: \.thread.id) diff --git a/packages/DerrickBackend/Sources/DerrickBackend/MessagingIngressService.swift b/packages/DerrickBackend/Sources/DerrickBackend/MessagingIngressService.swift index 0f61b6d2..d1ec96f5 100644 --- a/packages/DerrickBackend/Sources/DerrickBackend/MessagingIngressService.swift +++ b/packages/DerrickBackend/Sources/DerrickBackend/MessagingIngressService.swift @@ -50,41 +50,53 @@ public final class MessagingIngressService: @unchecked Sendable { do { let repository = try await DaemonRuntime.shared.sharedRepository() let connectors = try await repository.listMessagingConnectors(listeningOnly: true) - guard !connectors.isEmpty else { return } - - channelSyncGeneration &+= 1 - let shouldSyncChannels = channelSyncGeneration % channelSyncEveryPolls == 1 var newRows: [MessagingPersistResult] = [] - for connector in connectors { - guard let adapter = MessagingIngressRegistry.adapter(for: connector.pluginID) else { - continue - } - guard adapter.hasCredentials() else { continue } - let manifestJSON = try await repository.listLatestPluginFactoryManifests() - .first(where: { $0.pluginID == connector.pluginID })? - .manifestJSON ?? "" - if shouldSyncChannels, - PluginFactoryValidationExpectations.supportsSyncThreads(manifestJSON: manifestJSON) { - try await adapter.syncThreads(repository: repository) - } - if PluginFactoryValidationExpectations.supportsPollInbox(manifestJSON: manifestJSON) { - let inserted = try await adapter.pollInbox(repository: repository) - newRows.append(contentsOf: inserted.filter { - $0.inserted && $0.message.direction == .inbound - }) + if !connectors.isEmpty { + channelSyncGeneration &+= 1 + let shouldSyncChannels = channelSyncGeneration % channelSyncEveryPolls == 1 + + for connector in connectors { + do { + guard let adapter = MessagingIngressRegistry.adapter(for: connector.pluginID) else { + continue + } + guard adapter.hasCredentials() else { continue } + let manifestJSON = try await repository.listLatestPluginFactoryManifests() + .first(where: { $0.pluginID == connector.pluginID })? + .manifestJSON ?? "" + if shouldSyncChannels, + PluginFactoryValidationExpectations.supportsSyncThreads(manifestJSON: manifestJSON) { + try await adapter.syncThreads(repository: repository) + } + if PluginFactoryValidationExpectations.supportsPollInbox(manifestJSON: manifestJSON) { + let inserted = try await adapter.pollInbox(repository: repository) + newRows.append(contentsOf: inserted.filter { + $0.inserted && $0.message.direction == .inbound + }) + } + } catch { + fputs( + "[MessagingIngressService] poll failed pluginID=\(connector.pluginID): \(error.localizedDescription)\n", + stderr + ) + } } } if !newRows.isEmpty { DerrickMessagingInboundSignal.postRefresh() await MessagingInboundNotifier.notifyNewInbound(newRows) - await MessagingAgentIngressRouter.processInbound(newRows, repository: repository) fputs( "[MessagingIngressService] persisted \(newRows.count) inbound message(s)\n", stderr ) } + try? await repository.releaseUnansweredProfileTokenClaims() + let unclaimed = (try? await repository.listUnclaimedInboundMessagingMessages()) ?? [] + if !unclaimed.isEmpty { + await MessagingAgentIngressRouter.processInbound(unclaimed, repository: repository) + } } catch { fputs("[MessagingIngressService] poll failed: \(error.localizedDescription)\n", stderr) } diff --git a/packages/DerrickBackend/Sources/DerrickBackend/PluginMessagingIngressAdapter.swift b/packages/DerrickBackend/Sources/DerrickBackend/PluginMessagingIngressAdapter.swift index 129d6313..1fec8644 100644 --- a/packages/DerrickBackend/Sources/DerrickBackend/PluginMessagingIngressAdapter.swift +++ b/packages/DerrickBackend/Sources/DerrickBackend/PluginMessagingIngressAdapter.swift @@ -44,7 +44,7 @@ public final class PluginMessagingIngressAdapter: MessagingIngressAdapter, @unch .first(where: { $0.pluginID == pluginID }) var inserted: [MessagingPersistResult] = [] - var replyPollsRemaining = 1 + var replyPollsRemaining = 8 for thread in threads { inserted.append( contentsOf: try await pollConversation( diff --git a/packages/DerrickBackend/Tests/DerrickBackendTests/DerrickBackendTests.swift b/packages/DerrickBackend/Tests/DerrickBackendTests/DerrickBackendTests.swift index 8a2cd015..851efb52 100644 --- a/packages/DerrickBackend/Tests/DerrickBackendTests/DerrickBackendTests.swift +++ b/packages/DerrickBackend/Tests/DerrickBackendTests/DerrickBackendTests.swift @@ -219,4 +219,35 @@ import Testing await MessagingInboundNotifier.notifyNewInbound(rows, uiIsInteractive: true) await MessagingInboundNotifier.notifyNewInbound(rows, uiIsInteractive: false) } + + @Test func messagingInboundNotifierSuppressesOSBannersForViewedConnector() { + let thread = MessagingThreadDTO( + id: "thread-general", + pluginID: "slack-bot", + vendorThreadID: "C1", + title: "#general" + ) + let rows = [ + MessagingPersistResult( + inserted: true, + message: MessagingMessageDTO( + threadID: thread.id, + direction: .inbound, + sender: "ada", + body: "hello" + ), + thread: thread + ) + ] + let suppressed = MessagingInboundNotifier.notificationRequests( + from: rows, + suppressingPluginID: "slack-bot" + ) + #expect(suppressed.isEmpty) + let other = MessagingInboundNotifier.notificationRequests( + from: rows, + suppressingPluginID: "other-connector" + ) + #expect(other.count == 1) + } } diff --git a/packages/DerrickBackend/Tests/DerrickBackendTests/MessagingAgentIngressRouterTests.swift b/packages/DerrickBackend/Tests/DerrickBackendTests/MessagingAgentIngressRouterTests.swift index 0611c200..95540c98 100644 --- a/packages/DerrickBackend/Tests/DerrickBackendTests/MessagingAgentIngressRouterTests.swift +++ b/packages/DerrickBackend/Tests/DerrickBackendTests/MessagingAgentIngressRouterTests.swift @@ -4,7 +4,7 @@ import Structure import Testing @testable import DerrickBackend -@Suite struct MessagingAgentIngressRouterTests { +@Suite(.serialized) struct MessagingAgentIngressRouterTests { @Test func processInboundSkipsMessagesWithoutBotMention() async throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -60,4 +60,299 @@ import Testing await MessagingAgentIngressRouter.processInbound([row], repository: repository) #expect(counter.value == 0) } + + @Test func processInboundRoutesBareProfileTokenWithoutBotMention() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let repository = DBRepository( + configuration: DBRepositoryConfiguration( + applicationName: "ui", + databaseName: "derrick", + databaseDirectoryURL: directory, + username: "app-user", + password: "app-secret" + ) + ) + _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") + + final class CapturedRoute: @unchecked Sendable { + private let lock = NSLock() + private var route: MessagingAgentRoute? + func store(_ value: MessagingAgentRoute) { + lock.lock() + route = value + lock.unlock() + } + var value: MessagingAgentRoute? { + lock.lock() + defer { lock.unlock() } + return route + } + } + let captured = CapturedRoute() + InProcessServiceBridges.messagingAgentRoute = { route in + captured.store(route) + } + defer { InProcessServiceBridges.messagingAgentRoute = nil } + + let row = MessagingPersistResult( + inserted: true, + message: MessagingMessageDTO( + threadID: "thread-1", + vendorMessageID: "1710000002.000200", + direction: .inbound, + sender: "U07FKG8DV19", + body: "$orchestrator tell me about yourself" + ), + thread: MessagingThreadDTO( + id: "thread-1", + pluginID: "slack-connection", + vendorThreadID: "C123", + title: "general" + ) + ) + + await MessagingAgentIngressRouter.processInbound([row], repository: repository) + #expect(captured.value?.profileHandle == AgentProfileHandle.orchestrator) + #expect(captured.value?.prompt == "tell me about yourself") + #expect(captured.value?.parentVendorMessageID == "1710000002.000200") + } + + @Test func processInboundRoutesProfileTokenAfterGreeting() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let repository = DBRepository( + configuration: DBRepositoryConfiguration( + applicationName: "ui", + databaseName: "derrick", + databaseDirectoryURL: directory, + username: "app-user", + password: "app-secret" + ) + ) + _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") + + final class CapturedRoute: @unchecked Sendable { + private let lock = NSLock() + private var route: MessagingAgentRoute? + func store(_ value: MessagingAgentRoute) { + lock.lock() + route = value + lock.unlock() + } + var value: MessagingAgentRoute? { + lock.lock() + defer { lock.unlock() } + return route + } + } + let captured = CapturedRoute() + InProcessServiceBridges.messagingAgentRoute = { route in + captured.store(route) + } + defer { InProcessServiceBridges.messagingAgentRoute = nil } + + let row = MessagingPersistResult( + inserted: true, + message: MessagingMessageDTO( + threadID: "thread-1", + vendorMessageID: "1710000004.000400", + direction: .inbound, + sender: "U07FKG8DV19", + body: "hi $orchestrator how are you?" + ), + thread: MessagingThreadDTO( + id: "thread-1", + pluginID: "slack-connection", + vendorThreadID: "C123", + title: "general" + ) + ) + + await MessagingAgentIngressRouter.processInbound([row], repository: repository) + #expect(captured.value?.profileHandle == AgentProfileHandle.orchestrator) + #expect(captured.value?.prompt == "hi how are you?") + } + + @Test func processInboundKeepsExistingThreadParent() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let repository = DBRepository( + configuration: DBRepositoryConfiguration( + applicationName: "ui", + databaseName: "derrick", + databaseDirectoryURL: directory, + username: "app-user", + password: "app-secret" + ) + ) + _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") + + final class CapturedRoute: @unchecked Sendable { + private let lock = NSLock() + private var route: MessagingAgentRoute? + func store(_ value: MessagingAgentRoute) { + lock.lock() + route = value + lock.unlock() + } + var value: MessagingAgentRoute? { + lock.lock() + defer { lock.unlock() } + return route + } + } + let captured = CapturedRoute() + InProcessServiceBridges.messagingAgentRoute = { route in + captured.store(route) + } + defer { InProcessServiceBridges.messagingAgentRoute = nil } + + let row = MessagingPersistResult( + inserted: false, + message: MessagingMessageDTO( + threadID: "thread-1", + vendorMessageID: "1710000003.000300", + direction: .inbound, + sender: "U07FKG8DV19", + body: "$developer fix the build", + parentVendorMessageID: "1710000002.000200" + ), + thread: MessagingThreadDTO( + id: "thread-1", + pluginID: "slack-connection", + vendorThreadID: "C123", + title: "general" + ) + ) + + await MessagingAgentIngressRouter.processInbound([row], repository: repository) + #expect(captured.value?.profileHandle == "developer") + #expect(captured.value?.parentVendorMessageID == "1710000002.000200") + } + + @Test func processInboundContinuesThreadProfileWithoutDollarToken() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let repository = DBRepository( + configuration: DBRepositoryConfiguration( + applicationName: "ui", + databaseName: "derrick", + databaseDirectoryURL: directory, + username: "app-user", + password: "app-secret" + ) + ) + _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") + try await repository.upsertMessagingConnector( + MessagingConnectorDTO(pluginID: "slack-connection", displayName: "Slack") + ) + _ = try await repository.persistMessagingInbound( + MessagingInboundRecord( + pluginID: "slack-connection", + vendorThreadID: "C123", + threadTitle: "general", + vendorMessageID: "1710000002.000200", + sender: "U07FKG8DV19", + body: "$orchestrator what's today's date?" + ) + ) + try await repository.insertMessagingMessage( + MessagingMessageDTO( + threadID: (try await repository.listMessagingThreads(pluginID: "slack-connection"))[0].id, + vendorMessageID: "1710000002.000201", + direction: .outbound, + sender: "derrick", + body: "[Derrick:orchestrator] Today is Tuesday.", + parentVendorMessageID: "1710000002.000200" + ), + incrementUnread: false + ) + let followUp = try await repository.persistMessagingInbound( + MessagingInboundRecord( + pluginID: "slack-connection", + vendorThreadID: "C123", + threadTitle: "general", + vendorMessageID: "1710000002.000202", + sender: "U07FKG8DV19", + body: "and what day of the week is that?", + parentVendorMessageID: "1710000002.000200" + ) + ) + + final class CapturedRoute: @unchecked Sendable { + private let lock = NSLock() + private var route: MessagingAgentRoute? + func store(_ value: MessagingAgentRoute) { + lock.lock() + route = value + lock.unlock() + } + var value: MessagingAgentRoute? { + lock.lock() + defer { lock.unlock() } + return route + } + } + let captured = CapturedRoute() + InProcessServiceBridges.messagingAgentRoute = { route in + captured.store(route) + } + defer { InProcessServiceBridges.messagingAgentRoute = nil } + + await MessagingAgentIngressRouter.processInbound([followUp], repository: repository) + #expect(captured.value?.profileHandle == AgentProfileHandle.orchestrator) + #expect(captured.value?.prompt == "and what day of the week is that?") + #expect(captured.value?.parentVendorMessageID == "1710000002.000200") + } + + @Test func processInboundReleasesClaimWhenRouteHandlerThrows() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let repository = DBRepository( + configuration: DBRepositoryConfiguration( + applicationName: "ui", + databaseName: "derrick", + databaseDirectoryURL: directory, + username: "app-user", + password: "app-secret" + ) + ) + _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") + + struct RouteFailure: Error {} + InProcessServiceBridges.messagingAgentRoute = { _ in + throw RouteFailure() + } + defer { InProcessServiceBridges.messagingAgentRoute = nil } + + let row = MessagingPersistResult( + inserted: true, + message: MessagingMessageDTO( + threadID: "thread-1", + vendorMessageID: "1710000009.000900", + direction: .inbound, + sender: "U07FKG8DV19", + body: "$orchestrator ping" + ), + thread: MessagingThreadDTO( + id: "thread-1", + pluginID: "slack-connection", + vendorThreadID: "C123", + title: "general" + ) + ) + + await MessagingAgentIngressRouter.processInbound([row], repository: repository) + let retryClaim = try await repository.claimMessagingAgentHandling( + pluginID: "slack-connection", + vendorMessageID: "1710000009.000900" + ) + #expect(retryClaim) + } } diff --git a/packages/DerrickBackend/Tests/DerrickBackendTests/PluginMessagingIngressAdapterTests.swift b/packages/DerrickBackend/Tests/DerrickBackendTests/PluginMessagingIngressAdapterTests.swift index c69abcfc..c2c11edc 100644 --- a/packages/DerrickBackend/Tests/DerrickBackendTests/PluginMessagingIngressAdapterTests.swift +++ b/packages/DerrickBackend/Tests/DerrickBackendTests/PluginMessagingIngressAdapterTests.swift @@ -420,7 +420,7 @@ import Testing } let adapter = PluginMessagingIngressAdapter(pluginID: "slack-connection", invoker: invoker) _ = try await adapter.pollInbox(repository: repository) - #expect(capture.parents == ["", "171.3"]) + #expect(capture.parents == ["", "171.3", "171.1"]) } @Test func pollInboxRefetchesReplyThreadWhenStoredCountMatches() async throws { diff --git a/packages/Structure/Sources/AgentRuntime/AgentProfile.swift b/packages/Structure/Sources/AgentRuntime/AgentProfile.swift index be9c211b..91b2b57b 100644 --- a/packages/Structure/Sources/AgentRuntime/AgentProfile.swift +++ b/packages/Structure/Sources/AgentRuntime/AgentProfile.swift @@ -46,22 +46,104 @@ public enum AgentProfileHandle { } } -/// Parses `$handle` at the start of a message body (after optional bot mention stripping). +/// Parses `$handle` anywhere in a message (token boundary), not only at the start. public enum AgentProfileTokenParser { public static func parse(message: String) -> (handle: String?, body: String) { let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("$") else { + guard let tokenRange = AgentProfileTokenHighlight.ranges(in: trimmed).first(where: { + trimmed[$0].hasPrefix("$") + }) else { return (nil, trimmed) } - let remainder = String(trimmed.dropFirst()) - guard let end = remainder.firstIndex(where: { $0 == " " || $0 == "\n" || $0 == "\t" }) else { - let handle = AgentProfileHandle.normalize(remainder) - return (handle, "") + let token = String(trimmed[tokenRange]) + let handle = AgentProfileHandle.normalize(String(token.dropFirst())) + var body = trimmed + body.removeSubrange(tokenRange) + let collapsed = body + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + return (handle, collapsed) + } +} + +/// Ranges of `$handle` tokens (and the profile name inside `[Derrick:handle]`) for UI highlighting. +public enum AgentProfileTokenHighlight { + public static func ranges( + in text: String, + productName: String = DerrickAppSupport.hostAppProductName + ) -> [Range] { + var found: [Range] = [] + found.append(contentsOf: dollarHandleRanges(in: text)) + found.append(contentsOf: productPrefixedHandleRanges(in: text, productName: productName)) + return found.sorted { $0.lowerBound < $1.lowerBound } + } + + public static func nsRanges( + in text: String, + productName: String = DerrickAppSupport.hostAppProductName + ) -> [NSRange] { + ranges(in: text, productName: productName).map { NSRange($0, in: text) } + } + + private static func dollarHandleRanges(in text: String) -> [Range] { + var ranges: [Range] = [] + var index = text.startIndex + while index < text.endIndex { + if text[index] == "$", isTokenBoundary(before: index, in: text) { + let handleStart = text.index(after: index) + var handleEnd = handleStart + while handleEnd < text.endIndex, isHandleCharacter(text[handleEnd]) { + handleEnd = text.index(after: handleEnd) + } + let handle = String(text[handleStart.. [Range] { + let needle = "[\(productName):" + var ranges: [Range] = [] + var searchFrom = text.startIndex + while searchFrom < text.endIndex, + let prefix = text.range(of: needle, range: searchFrom.. Bool { + AgentProfileHandle.isValid(handle) && handle.contains(where: \.isLetter) + } + + private static func isTokenBoundary(before index: String.Index, in text: String) -> Bool { + guard index > text.startIndex else { return true } + let previous = text[text.index(before: index)] + return !previous.isLetter && !previous.isNumber && previous != "_" + } + + private static func isHandleCharacter(_ character: Character) -> Bool { + character.unicodeScalars.allSatisfy { scalar in + CharacterSet.alphanumerics.contains(scalar) || scalar == "_" } - let token = String(remainder[.. URL { let fm = FileManager.default - let candidates = preferredDatabaseParentDirectories() - guard let parent = candidates.first else { - throw CocoaError(.fileNoSuchFile) + if let groupParent = appGroupApplicationSupportURL(), + let directoryURL = try resolveWritableDatabaseDirectory( + parent: groupParent, + applicationName: applicationName, + fileManager: fm + ) { + try migrateLegacyDatabaseIfNeeded(into: directoryURL) + return directoryURL } - let directoryURL = parent.appendingPathComponent(applicationName, isDirectory: true) - try fm.createDirectory(at: directoryURL, withIntermediateDirectories: true) - try migrateLegacyDatabaseIfNeeded(into: directoryURL) - return directoryURL + if DerrickProcessRole.isDaemon { + // Unsigned local builds cannot embed App Group entitlements. The UI falls back to + // the host container; the unsandboxed daemon must open that same path. + if let directoryURL = try resolveWritableDatabaseDirectory( + parent: hostContainerApplicationSupportURL(), + applicationName: applicationName, + fileManager: fm + ) { + try migrateLegacyDatabaseIfNeeded(into: directoryURL) + return directoryURL + } + throw DerrickAppSupportError.sharedDatabaseUnavailable( + """ + The background service cannot open the shared database. This usually means JobKeepAlive was built without App Group entitlements. + + Quit Derrick, rebuild from Xcode with your development team enabled, then open Derrick again. If it still fails, remove Derrick from Login Items, then reopen the app. + """ + ) + } + + let hostParent = hostContainerApplicationSupportURL() + if let directoryURL = try resolveWritableDatabaseDirectory( + parent: hostParent, + applicationName: applicationName, + fileManager: fm + ) { + try migrateLegacyDatabaseIfNeeded(into: directoryURL) + return directoryURL + } + + throw CocoaError(.fileNoSuchFile) + } + + /// Singleton lock for `derrickd`. Lives outside the App Group so unsigned dev builds can still coordinate. + public static func daemonSingletonLockURL() -> URL { + let directory = homeApplicationSupportDirectory() + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appendingPathComponent("derrickd.lock", isDirectory: false) } /// Ordered: App Group Application Support, host app container, process Application Support. public static func preferredDatabaseParentDirectories() -> [URL] { var urls: [URL] = [] + if let groupParent = appGroupApplicationSupportURL() { + urls.append(groupParent) + } + urls.append(hostContainerApplicationSupportURL()) let fm = FileManager.default - - if let groupRoot = fm.containerURL(forSecurityApplicationGroupIdentifier: applicationGroupIdentifier) { - urls.append( - groupRoot.appendingPathComponent("Library/Application Support", isDirectory: true) - ) + if let processSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + let host = hostContainerApplicationSupportURL() + if processSupport.standardizedFileURL != host.standardizedFileURL { + urls.append(processSupport) + } } + return urls + } - let home = fm.homeDirectoryForCurrentUser - let containerSupport = home + private static func appGroupApplicationSupportURL() -> URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: applicationGroupIdentifier)? + .appendingPathComponent("Library/Application Support", isDirectory: true) + } + + private static func hostContainerApplicationSupportURL() -> URL { + FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent( "Library/Containers/\(hostAppBundleIdentifier)/Data/Library/Application Support", isDirectory: true ) - urls.append(containerSupport) + } - if let processSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { - if processSupport.standardizedFileURL != containerSupport.standardizedFileURL { - urls.append(processSupport) - } + private static func homeApplicationSupportDirectory() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/Derrick", isDirectory: true) + } + + private static func resolveWritableDatabaseDirectory( + parent: URL, + applicationName: String, + fileManager: FileManager + ) throws -> URL? { + let directoryURL = parent.appendingPathComponent(applicationName, isDirectory: true) + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + guard canWriteProbe(in: directoryURL, fileManager: fileManager) else { + return nil + } + return directoryURL + } + + private static func canWriteProbe(in directoryURL: URL, fileManager: FileManager) -> Bool { + let probeURL = directoryURL.appendingPathComponent(".derrick-db-probe", isDirectory: false) + do { + try Data("ok".utf8).write(to: probeURL, options: .atomic) + try fileManager.removeItem(at: probeURL) + return true + } catch { + fputs( + "[DerrickAppSupport] database probe failed \(directoryURL.path): \(error.localizedDescription)\n", + stderr + ) + return false } - return urls } /// Copy `derrick.sqlite3` (+ WAL/SHM) from host container into the group directory when diff --git a/packages/Structure/Sources/AppLayerServices/AppServices/DerrickMessagingForegroundPresence.swift b/packages/Structure/Sources/AppLayerServices/AppServices/DerrickMessagingForegroundPresence.swift new file mode 100644 index 00000000..63b4386a --- /dev/null +++ b/packages/Structure/Sources/AppLayerServices/AppServices/DerrickMessagingForegroundPresence.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Cross-process: which messaging connector the interactive UI is showing. +/// +/// derrickd skips macOS banners for that connector while Derrick is frontmost +/// on Messaging, so the UI can show a short in-app banner instead. +public enum DerrickMessagingForegroundPresence: Sendable { + private static let fileName = "messaging_foreground.json" + + private struct Record: Codable, Sendable { + let pid: Int32 + let pluginID: String? + let isMessagingWorkspace: Bool + let isFrontmost: Bool + } + + public static func sync( + isMessagingWorkspace: Bool, + pluginID: String?, + isFrontmost: Bool, + pid: Int32 = ProcessInfo.processInfo.processIdentifier + ) { + guard let url = fileURL() else { return } + let trimmed = pluginID?.trimmingCharacters(in: .whitespacesAndNewlines) + let record = Record( + pid: pid, + pluginID: (trimmed?.isEmpty == false) ? trimmed : nil, + isMessagingWorkspace: isMessagingWorkspace, + isFrontmost: isFrontmost + ) + guard let data = try? JSONEncoder().encode(record) else { return } + try? data.write(to: url, options: .atomic) + } + + public static func clear() { + guard let url = fileURL() else { return } + try? FileManager.default.removeItem(at: url) + } + + /// Plugin ID whose inbound OS banners should be suppressed. + public static func pluginIDForSuppressedOSNotifications( + excludingPID: Int32 = ProcessInfo.processInfo.processIdentifier + ) -> String? { + guard let record = readRecord() else { return nil } + guard record.pid != excludingPID else { return nil } + guard isProcessAlive(record.pid) else { return nil } + guard record.isMessagingWorkspace, record.isFrontmost else { return nil } + let pluginID = record.pluginID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return pluginID.isEmpty ? nil : pluginID + } + + private static func readRecord() -> Record? { + guard let url = fileURL(), + let data = try? Data(contentsOf: url) + else { + return nil + } + return try? JSONDecoder().decode(Record.self, from: data) + } + + private static func fileURL() -> URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: DerrickAppSupport.applicationGroupIdentifier)? + .appendingPathComponent(fileName, isDirectory: false) + } + + private static func isProcessAlive(_ pid: Int32) -> Bool { + guard pid > 0 else { return false } + return kill(pid, 0) == 0 + } +} diff --git a/packages/Structure/Sources/Plugin/Messaging/ConnectorMentionRouting.swift b/packages/Structure/Sources/Plugin/Messaging/ConnectorMentionRouting.swift index 1a2bf441..aae841c2 100644 --- a/packages/Structure/Sources/Plugin/Messaging/ConnectorMentionRouting.swift +++ b/packages/Structure/Sources/Plugin/Messaging/ConnectorMentionRouting.swift @@ -61,6 +61,27 @@ public enum AgentProfileHelpFormatter: Sendable { public enum ConnectorMentionParser: Sendable { public static let botReplyPrefix = "[\(DerrickAppSupport.hostAppProductName)]" + public static func botReplyPrefix(profileHandle: String) -> String { + let handle = AgentProfileHandle.normalize(profileHandle) + ?? profileHandle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if handle.isEmpty { + return botReplyPrefix + } + return "[\(DerrickAppSupport.hostAppProductName):\(handle)]" + } + + /// Slack-style thread: reply under the inbound message, or stay in an existing thread. + public static func agentReplyThreadParentVendorMessageID( + inboundVendorMessageID: String, + existingParentVendorMessageID: String? + ) -> String { + let existing = existingParentVendorMessageID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !existing.isEmpty { + return existing + } + return inboundVendorMessageID.trimmingCharacters(in: .whitespacesAndNewlines) + } + public static func mentionsSlackUser(body: String, userID: String) -> Bool { let trimmedID = userID.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedID.isEmpty else { return false } @@ -81,27 +102,82 @@ public enum ConnectorMentionParser: Sendable { } public static func isAutomatedOutboundEcho(body: String) -> Bool { - body.trimmingCharacters(in: .whitespacesAndNewlines) - .hasPrefix(botReplyPrefix) + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("[") else { return false } + let name = DerrickAppSupport.hostAppProductName + return trimmed.hasPrefix("[\(name)]") || trimmed.hasPrefix("[\(name):") } public static func resolvePrompt( body: String, botUserID: String, + continuationProfileHandle: String? = nil, channelDefaultProfileHandle: String? = nil, profileCatalog: [AgentProfileCatalogEntry] = [] ) -> (profileHandle: String, prompt: String)? { - guard mentionsSlackUser(body: body, userID: botUserID) else { return nil } - let withoutMention = stripSlackUserMention(body: body, userID: botUserID) + let mentioned = mentionsSlackUser(body: body, userID: botUserID) + let withoutMention = mentioned + ? stripSlackUserMention(body: body, userID: botUserID) + : body.trimmingCharacters(in: .whitespacesAndNewlines) let parsed = AgentProfileTokenParser.parse(message: withoutMention) let channelDefault = channelDefaultProfileHandle.flatMap { AgentProfileHandle.normalize($0) } - let handle = parsed.handle ?? channelDefault ?? AgentProfileHandle.orchestrator - let prompt = parsed.body.trimmingCharacters(in: .whitespacesAndNewlines) - guard !prompt.isEmpty else { + let continuation = AgentProfileHandle.normalize(continuationProfileHandle ?? "") + + let handle: String + let promptSource: String + if let parsedHandle = parsed.handle { + handle = parsedHandle + promptSource = parsed.body + } else if mentioned { + handle = channelDefault ?? AgentProfileHandle.orchestrator + promptSource = parsed.body + } else if let continuation { + handle = continuation + promptSource = withoutMention + } else { + return nil + } + + let prompt = promptSource.trimmingCharacters(in: .whitespacesAndNewlines) + if prompt.isEmpty { return (handle, AgentProfileHelpFormatter.mentionOnlyPrompt(catalog: profileCatalog)) } return (handle, prompt) } + + /// `$handle` token anywhere in the body, or `[Derrick:handle]` on an automated reply. + public static func profileHandle(inMessageBody body: String) -> String? { + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + if let handle = AgentProfileTokenParser.parse(message: trimmed).handle { + return handle + } + let name = DerrickAppSupport.hostAppProductName + let prefix = "[\(name):" + guard trimmed.hasPrefix(prefix), + let close = trimmed[prefix.endIndex...].firstIndex(of: "]") + else { + return nil + } + return AgentProfileHandle.normalize(String(trimmed[prefix.endIndex.. String? { + let excluded = excludingVendorMessageID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + for message in messages.reversed() { + let vendorID = message.vendorMessageID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !excluded.isEmpty, vendorID == excluded { + continue + } + if let handle = profileHandle(inMessageBody: message.body) { + return handle + } + } + return nil + } } public enum SlackBotIdentityResolver: Sendable { @@ -174,8 +250,8 @@ public enum MessagingAgentSessionID { } public enum MessagingAgentOutboundFormatter { - public static func formatReply(_ text: String) -> String { + public static func formatReply(_ text: String, profileHandle: String) -> String { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return "\(ConnectorMentionParser.botReplyPrefix) \(trimmed)" + return "\(ConnectorMentionParser.botReplyPrefix(profileHandle: profileHandle)) \(trimmed)" } } diff --git a/packages/Structure/Tests/StructureTests/AgentProfileTests.swift b/packages/Structure/Tests/StructureTests/AgentProfileTests.swift index a9382b28..fac49231 100644 --- a/packages/Structure/Tests/StructureTests/AgentProfileTests.swift +++ b/packages/Structure/Tests/StructureTests/AgentProfileTests.swift @@ -21,6 +21,22 @@ import Testing #expect(parsed.body == "") } + @Test func tokenParserFindsHandleAfterGreeting() { + let parsed = AgentProfileTokenParser.parse(message: "hi $orchestrator how are you?") + #expect(parsed.handle == "orchestrator") + #expect(parsed.body == "hi how are you?") + + let handleOnlyGreeting = AgentProfileTokenParser.parse(message: "hi $orchestrator") + #expect(handleOnlyGreeting.handle == "orchestrator") + #expect(handleOnlyGreeting.body == "hi") + } + + @Test func tokenParserIgnoresDollarAmounts() { + let parsed = AgentProfileTokenParser.parse(message: "price is $100") + #expect(parsed.handle == nil) + #expect(parsed.body == "price is $100") + } + @Test func handleValidationRejectsInvalidCharacters() { #expect(AgentProfileHandle.isValid("reviewer")) #expect(AgentProfileHandle.isValid("code_reviewer_2")) @@ -54,4 +70,17 @@ import Testing AgentProfileHandle.general, ]) } + + @Test func tokenHighlightFindsDollarHandlesAndPrefixedProfileNames() { + let inbound = "$orchestrator tell me about yourself" + let inboundTokens = AgentProfileTokenHighlight.ranges(in: inbound).map { String(inbound[$0]) } + #expect(inboundTokens == ["$orchestrator"]) + + let outbound = "[Derrick:developer] Fixed the build." + let outboundTokens = AgentProfileTokenHighlight.ranges(in: outbound).map { String(outbound[$0]) } + #expect(outboundTokens == ["developer"]) + + let ignored = AgentProfileTokenHighlight.ranges(in: "price is $100") + #expect(ignored.isEmpty) + } } diff --git a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift index 3b20dec3..5d8b2588 100644 --- a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift +++ b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift @@ -138,6 +138,17 @@ import Testing #expect(DerrickServiceID.job.xpcServiceName == "derrick.ui.JobService") } + @Test func daemonSingletonLockURLUsesHomeApplicationSupport() { + let url = DerrickAppSupport.daemonSingletonLockURL() + #expect(url.lastPathComponent == "derrickd.lock") + #expect(url.path.contains("Library/Application Support/Derrick")) + } + + @Test func sharedDatabaseUnavailableErrorIsLocalized() { + let error = DerrickAppSupportError.sharedDatabaseUnavailable("test detail") + #expect(error.errorDescription == "test detail") + } + @Test func databaseDirectoryPrefersAppGroupThenHostContainer() { let parents = DerrickAppSupport.preferredDatabaseParentDirectories() #expect(!parents.isEmpty) @@ -800,6 +811,29 @@ import Testing #expect(!DerrickUISessionPresence.isInteractiveSessionActive()) } + @Test func derrickMessagingForegroundPresenceSuppressesViewedConnector() { + guard appGroupCrossProcessStorageIsAvailable() else { return } + DerrickMessagingForegroundPresence.clear() + defer { DerrickMessagingForegroundPresence.clear() } + DerrickMessagingForegroundPresence.sync( + isMessagingWorkspace: true, + pluginID: "slack-connector-1", + isFrontmost: true + ) + #expect( + DerrickMessagingForegroundPresence.pluginIDForSuppressedOSNotifications(excludingPID: -1) + == "slack-connector-1" + ) + DerrickMessagingForegroundPresence.sync( + isMessagingWorkspace: true, + pluginID: "slack-connector-1", + isFrontmost: false + ) + #expect( + DerrickMessagingForegroundPresence.pluginIDForSuppressedOSNotifications(excludingPID: -1) == nil + ) + } + @Test func derrickDaemonHygieneRestartAfterOrphanEviction() { #expect( DerrickDaemonHygiene.shouldRestartDaemonAfterReconcile( diff --git a/packages/Structure/Tests/StructureTests/ConnectorMentionRoutingTests.swift b/packages/Structure/Tests/StructureTests/ConnectorMentionRoutingTests.swift index f409b773..840d94ee 100644 --- a/packages/Structure/Tests/StructureTests/ConnectorMentionRoutingTests.swift +++ b/packages/Structure/Tests/StructureTests/ConnectorMentionRoutingTests.swift @@ -33,6 +33,39 @@ import Testing #expect(defaultProfile?.prompt == "what is blocking release?") } + @Test func resolvePromptRoutesBareProfileTokenWithoutBotMention() { + let routed = ConnectorMentionParser.resolvePrompt( + body: "$orchestrator tell me about yourself", + botUserID: "U123" + ) + #expect(routed?.profileHandle == AgentProfileHandle.orchestrator) + #expect(routed?.prompt == "tell me about yourself") + + let withoutBotIdentity = ConnectorMentionParser.resolvePrompt( + body: "$developer fix the build", + botUserID: "" + ) + #expect(withoutBotIdentity?.profileHandle == "developer") + #expect(withoutBotIdentity?.prompt == "fix the build") + } + + @Test func resolvePromptRoutesProfileTokenAfterGreeting() { + let routed = ConnectorMentionParser.resolvePrompt( + body: "hi $orchestrator how are you?", + botUserID: "U123" + ) + #expect(routed?.profileHandle == AgentProfileHandle.orchestrator) + #expect(routed?.prompt == "hi how are you?") + } + + @Test func resolvePromptIgnoresPlainInboundWithoutMentionOrHandle() { + let ignored = ConnectorMentionParser.resolvePrompt( + body: "hello without mention", + botUserID: "U123" + ) + #expect(ignored == nil) + } + @Test func resolvePromptUsesChannelDefaultProfile() { let resolved = ConnectorMentionParser.resolvePrompt( body: "<@U123> what is blocking release?", @@ -56,13 +89,98 @@ import Testing #expect(prompt?.contains("$developer") == true) } - @Test func outboundFormatterPrefixesBotName() { - let formatted = MessagingAgentOutboundFormatter.formatReply("Done.") - #expect(formatted == "[Derrick] Done.") + @Test func outboundFormatterPrefixesBotNameAndProfile() { + let formatted = MessagingAgentOutboundFormatter.formatReply( + "Done.", + profileHandle: AgentProfileHandle.orchestrator + ) + #expect(formatted == "[Derrick:orchestrator] Done.") + + let developer = MessagingAgentOutboundFormatter.formatReply( + "Fixed the build.", + profileHandle: AgentProfileHandle.developer + ) + #expect(developer == "[Derrick:developer] Fixed the build.") + } + + @Test func automatedOutboundEchoDetectsProfilePrefixedReplies() { + #expect(ConnectorMentionParser.isAutomatedOutboundEcho(body: "[Derrick:orchestrator] hello")) + #expect(ConnectorMentionParser.isAutomatedOutboundEcho(body: "[Derrick] hello")) + #expect(ConnectorMentionParser.isAutomatedOutboundEcho(body: "plain inbound") == false) } @Test func parseBotUserIDFromAuthTestPayload() { let json = Data(#"{"ok":true,"user_id":"U07BOT","bot_id":"B07BOT"}"#.utf8) #expect(SlackBotIdentityResolver.parseBotUserID(from: json) == "U07BOT") } + + @Test func agentReplyThreadsUnderInboundRootWhenNoParent() { + let inbound = "1710000002.000200" + let parent = ConnectorMentionParser.agentReplyThreadParentVendorMessageID( + inboundVendorMessageID: inbound, + existingParentVendorMessageID: nil + ) + #expect(parent == inbound) + + let alreadyThreaded = ConnectorMentionParser.agentReplyThreadParentVendorMessageID( + inboundVendorMessageID: "1710000003.000300", + existingParentVendorMessageID: inbound + ) + #expect(alreadyThreaded == inbound) + } + + @Test func resolvePromptContinuesThreadProfileWithoutDollarToken() { + let continued = ConnectorMentionParser.resolvePrompt( + body: "tell me about yourself", + botUserID: "U123", + continuationProfileHandle: AgentProfileHandle.developer + ) + #expect(continued?.profileHandle == AgentProfileHandle.developer) + #expect(continued?.prompt == "tell me about yourself") + + let switched = ConnectorMentionParser.resolvePrompt( + body: "$developer tell me about yourself", + botUserID: "U123", + continuationProfileHandle: AgentProfileHandle.orchestrator + ) + #expect(switched?.profileHandle == AgentProfileHandle.developer) + } + + @Test func continuationProfileHandleUsesLatestDerrickReply() { + let messages = [ + MessagingMessageDTO( + threadID: "t1", + vendorMessageID: "1", + direction: .inbound, + sender: "U1", + body: "$orchestrator what's today's date?" + ), + MessagingMessageDTO( + threadID: "t1", + vendorMessageID: "2", + direction: .outbound, + sender: "derrick", + body: "[Derrick:orchestrator] Today is Tuesday.", + parentVendorMessageID: "1" + ), + MessagingMessageDTO( + threadID: "t1", + vendorMessageID: "3", + direction: .outbound, + sender: "derrick", + body: "[Derrick:developer] I’m the developer profile.", + parentVendorMessageID: "1" + ), + ] + #expect( + ConnectorMentionParser.continuationProfileHandle( + in: messages, + excludingVendorMessageID: "4" + ) == AgentProfileHandle.developer + ) + #expect( + ConnectorMentionParser.profileHandle(inMessageBody: "[Derrick:developer] hi") + == AgentProfileHandle.developer + ) + } } diff --git a/ui/JobKeepAlive/DaemonModuleBootstrap.swift b/ui/JobKeepAlive/DaemonModuleBootstrap.swift index 2f6d3644..dfae72f2 100644 --- a/ui/JobKeepAlive/DaemonModuleBootstrap.swift +++ b/ui/JobKeepAlive/DaemonModuleBootstrap.swift @@ -8,6 +8,9 @@ enum DaemonModuleBootstrap { static func startAllModules() async { // Mark in-process mesh ready before scheduler claims work. JobServiceMeshState.shared.markInProcessReady() + InProcessServiceBridges.messagingAgentRoute = { route in + try await MessagingAgentTurnClient.handle(route: route) + } do { _ = try await MCPServiceStore.shared.sharedRepository() @@ -67,9 +70,6 @@ enum DaemonModuleBootstrap { InProcessServiceBridges.connectorPoll = { request in try await ConnectorMessagingCommandService.shared.poll(request) } - InProcessServiceBridges.messagingAgentRoute = { route in - try await MessagingAgentTurnClient.handle(route: route) - } await DaemonRuntime.shared.markModuleReady(.mcp) fputs("[derrickd] module mcp ready\n", stderr) await sweepEmbeddedDockerLeftovers() diff --git a/ui/JobKeepAlive/DaemonSingleton.swift b/ui/JobKeepAlive/DaemonSingleton.swift index 66ead753..e772c994 100644 --- a/ui/JobKeepAlive/DaemonSingleton.swift +++ b/ui/JobKeepAlive/DaemonSingleton.swift @@ -6,10 +6,7 @@ import Structure /// steal `VUSK4B2YKQ.derrick.shared.daemon` and hang UI "Connecting to Derrick daemon". enum DaemonSingleton { static func acquireOrExit() { - guard let path = lockPath() else { - fputs("[derrickd] singleton lock skipped — no app group container\n", stderr) - return - } + let path = DerrickAppSupport.daemonSingletonLockURL().path let fd = open(path, O_CREAT | O_RDWR, 0o644) guard fd >= 0 else { fputs("[derrickd] singleton lock open failed \(path)\n", stderr) @@ -29,11 +26,4 @@ enum DaemonSingleton { fputs("[derrickd] singleton lock acquired pid=\(getpid()) path=\(path)\n", stderr) fflush(stderr) } - - private static func lockPath() -> String? { - FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: DerrickAppSupport.applicationGroupIdentifier)? - .appendingPathComponent("derrickd.lock", isDirectory: false) - .path - } } diff --git a/ui/JobService/MessagingAgentTurnClient.swift b/ui/JobService/MessagingAgentTurnClient.swift index 77a44c0f..461a1e49 100644 --- a/ui/JobService/MessagingAgentTurnClient.swift +++ b/ui/JobService/MessagingAgentTurnClient.swift @@ -28,7 +28,7 @@ enum MessagingAgentTurnClient { profileContextJSON: profileContextJSON ) let response = try await AgentServiceTurnHost.shared.runCollectedTurn(request: request) - let outbound = MessagingAgentOutboundFormatter.formatReply(response) + let outbound = MessagingAgentOutboundFormatter.formatReply(response, profileHandle: profile.handle) try await sendConnectorMessage( route: route, text: outbound, diff --git a/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift b/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift index 02c97e92..543dcda0 100644 --- a/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift +++ b/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift @@ -144,6 +144,17 @@ final class AppBootstrapStatus: ObservableObject { debugLog("[bootstrap] ignore phase=\(phase.rawValue) (already ready): \(message)") return } + // Parallel bootstrap: once we move past Docker prep, do not let guest-image + // prewarm overwrite daemon/database status in the modal. + if phase == .checkingDocker || phase == .preparingImage || phase == .verifyingEnvironment { + switch self.phase { + case .connectingHelper, .loadingSession: + debugLog("[bootstrap] ignore docker phase=\(phase.rawValue) while \(self.phase.rawValue): \(message)") + return + default: + break + } + } // Don't let a cancelled re-entrant task demote ready via failed paths above. self.phase = phase self.statusMessage = message @@ -255,6 +266,16 @@ final class AppBootstrapStatus: ObservableObject { message: "Docker Desktop is installed but not running, or Derrick cannot reach the Docker engine. Start Docker Desktop, wait until it is idle, then restart Derrick." ) } + if lower.contains("shared database") || lower.contains("authorization denied") { + return ClassifiedFailure( + title: "Background Service Cannot Open Database", + message: """ + Derrick finished preparing Docker, but the background service could not open the shared database. + + Quit Derrick completely, rebuild from Xcode with your development team enabled, then open Derrick again. If this continues, remove Derrick from Login Items and reopen the app. + """ + ) + } if lower.contains("timed out") || lower.contains("xpc call timed out") { return ClassifiedFailure( title: "Background Service Did Not Respond", diff --git a/ui/ui/AgentProfiles/AgentProfileHighlightedText.swift b/ui/ui/AgentProfiles/AgentProfileHighlightedText.swift new file mode 100644 index 00000000..a1eb4f03 --- /dev/null +++ b/ui/ui/AgentProfiles/AgentProfileHighlightedText.swift @@ -0,0 +1,28 @@ +import Structure +import SwiftUI + +enum AgentProfileTokenColor { + /// Darker than system green so `$profile` tokens stay readable on light bubbles. + static let darkGreen = Color(red: 0.0, green: 0.42, blue: 0.18) +} + +struct AgentProfileHighlightedText: View { + let text: String + var font: Font = .body + var baseColor: Color = .primary + + var body: some View { + Text(highlighted) + .font(font) + .foregroundStyle(baseColor) + } + + private var highlighted: AttributedString { + var attributed = AttributedString(text) + for nsRange in AgentProfileTokenHighlight.nsRanges(in: text) { + guard let range = Range(nsRange, in: attributed) else { continue } + attributed[range].foregroundColor = AgentProfileTokenColor.darkGreen + } + return attributed + } +} diff --git a/ui/ui/AgentProfiles/AgentProfileStore.swift b/ui/ui/AgentProfiles/AgentProfileStore.swift index 6a8888e0..227b2a92 100644 --- a/ui/ui/AgentProfiles/AgentProfileStore.swift +++ b/ui/ui/AgentProfiles/AgentProfileStore.swift @@ -100,7 +100,7 @@ enum AgentProfileStoreError: Error, LocalizedError { case .notReady: return "Agent profiles are not ready yet." case .invalidHandle: - return "Profile handle must use letters, numbers, and underscores." + return "Short name must use letters, numbers, and underscores." } } } diff --git a/ui/ui/Messaging/MessagingAgentRunner.swift b/ui/ui/Messaging/MessagingAgentRunner.swift deleted file mode 100644 index 8a0aa074..00000000 --- a/ui/ui/Messaging/MessagingAgentRunner.swift +++ /dev/null @@ -1,90 +0,0 @@ -import DBRepository -import Foundation -import LLMAgentClient -import Structure - -enum MessagingAgentRunner { - @MainActor - static func runAndRelay( - prompt: String, - profile: AgentProfile, - pluginID: String, - thread: MessagingThreadDTO, - parentVendorMessageID: String?, - connectorRuntime: ConnectorMessagingRuntime, - repository: DBRepository, - store: MessagingStore, - session: MessagingSessionStore - ) async throws { - let sessionID = MessagingAgentSessionID.make( - pluginID: pluginID, - threadID: thread.id, - profileHandle: profile.handle - ) - let model = (try? JSONDecoder().decode(LLMModelChoice.self, from: profile.modelJSON)) - ?? .defaultHelperModel - let apiKey = resolveAPIKey(for: model) ?? "" - let profileContextJSON = try JSONEncoder().encode(AgentProfileTurnContext(profile: profile)) - let modelJSON = try JSONEncoder().encode(model) - - try await AgentServiceClient.shared.ensureReadyForTurn() - let request = AgentTurnRequest( - sessionID: sessionID, - prompt: prompt, - apiKey: apiKey, - modelJSON: modelJSON, - thinkingJSON: profile.thinkingJSON, - profileContextJSON: profileContextJSON - ) - - var response = "" - let stream = AgentServiceClient.shared.streamTurn(request) - let streamStarted = Date() - let streamTimeoutSeconds: TimeInterval = 300 - for try await dto in stream { - if Date().timeIntervalSince(streamStarted) > streamTimeoutSeconds { - throw AgentServiceClientError.timeout - } - if dto.status == AgentResponseStatus.complete.rawValue, - let chunk = dto.chunk, - !chunk.isEmpty { - response += chunk - } - } - - let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - throw MessagingAgentRunnerError.emptyResponse - } - - let outbound = MessagingAgentOutboundFormatter.formatReply(trimmed) - try await connectorRuntime.send( - pluginID: pluginID, - text: outbound, - thread: thread, - parentVendorMessageID: parentVendorMessageID, - repository: repository, - store: store, - session: session - ) - } - - @MainActor - private static func resolveAPIKey(for model: LLMModelChoice) -> String? { - AppSecretResolver().resolve( - account: model.provider.secretAccount, - environmentKeys: model.provider.apiKeyEnvironmentKeys - ) - } -} - -enum MessagingAgentRunnerError: Error, LocalizedError { - case emptyResponse - - var errorDescription: String? { - switch self { - case .emptyResponse: - return "The agent did not return a response." - } - } -} diff --git a/ui/ui/Messaging/MessagingConversationView.swift b/ui/ui/Messaging/MessagingConversationView.swift index 554c6554..f0c8bc75 100644 --- a/ui/ui/Messaging/MessagingConversationView.swift +++ b/ui/ui/Messaging/MessagingConversationView.swift @@ -269,14 +269,30 @@ struct MessagingConversationView: View { } private var conversation: some View { - HStack(spacing: 0) { - channelPane - if store.isViewingReplyThread { - Divider() - threadPane - .frame(minWidth: 300, idealWidth: 360, maxWidth: 440) + ZStack(alignment: .top) { + HStack(spacing: 0) { + channelPane + if store.isViewingReplyThread { + Divider() + threadPane + .frame(minWidth: 300, idealWidth: 360, maxWidth: 440) + } + } + if let banner = store.inboundBanner, !banner.isEmpty { + Text(banner) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(2) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: 520) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) + .shadow(color: .black.opacity(0.12), radius: 8, y: 2) + .padding(.top, 10) + .transition(.move(edge: .top).combined(with: .opacity)) } } + .animation(.easeInOut(duration: 0.35), value: store.inboundBanner) .onChange(of: store.isViewingReplyThread) { _, open in if open { threadComposerFocused = true @@ -523,31 +539,9 @@ struct MessagingConversationView: View { .foregroundStyle(.secondary) } } - HStack(spacing: 12) { - Picker("Send mode", selection: $store.sendToAgent) { - Text("You").tag(false) - Text("Agent").tag(true) - } - .pickerStyle(.segmented) - .frame(maxWidth: 180) - - if store.sendToAgent { - Picker("Profile", selection: $store.selectedProfileHandle) { - ForEach(AgentProfileStore.shared.enabledProfiles, id: \.handle) { profile in - Text(profile.displayName).tag(profile.handle) - } - } - .labelsHidden() - } - } - if store.sendToAgent { - Text("Tip: type $handle at the start to switch profiles (e.g. $reviewer).") - .font(.caption2) - .foregroundStyle(.secondary) - } HStack(alignment: .bottom, spacing: 10) { TextField( - store.sendToAgent ? "Message for $\(store.selectedProfileHandle)…" : placeholder, + placeholder, text: text, axis: .vertical ) @@ -626,7 +620,7 @@ private struct MessagingBubble: View { HStack(alignment: .top, spacing: 0) { if message.direction == .outbound { Spacer(minLength: 80) } VStack(alignment: message.direction == .outbound ? .trailing : .leading, spacing: 4) { - Text(message.sender) + Text(message.sender) .font(.caption2) .foregroundStyle(.secondary) messageBody @@ -639,10 +633,12 @@ private struct MessagingBubble: View { Text(replyActionTitle) .font(.caption.weight(.semibold)) if message.replyCount > 0, let preview = lastReplyPreview, !preview.isEmpty { - Text(preview) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) + AgentProfileHighlightedText( + text: preview, + font: .caption2, + baseColor: .secondary + ) + .lineLimit(1) } } } @@ -661,25 +657,33 @@ private struct MessagingBubble: View { .fixedSize(horizontal: true, vertical: false) } } - .fixedSize(horizontal: true, vertical: false) + .frame(maxWidth: .infinity, alignment: message.direction == .outbound ? .trailing : .leading) if message.direction == .inbound { Spacer(minLength: 80) } } + .fixedSize(horizontal: false, vertical: true) } private var messageBody: some View { - Text(message.body) - .font(.system(size: 13)) - .multilineTextAlignment(.leading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(message.direction == .outbound - ? Color.black.opacity(0.08) - : Color.white) - ) - .frame(maxWidth: 260, alignment: .leading) - .fixedSize(horizontal: true, vertical: false) + ViewThatFits(in: .horizontal) { + bubbleLabel + .fixedSize() + .modifier(MessagingBubbleChrome(direction: message.direction)) + bubbleLabel + .fixedSize(horizontal: false, vertical: true) + .modifier(MessagingBubbleChrome(direction: message.direction)) + } + .frame( + maxWidth: .infinity, + alignment: message.direction == .outbound ? .trailing : .leading + ) + } + + private var bubbleLabel: some View { + AgentProfileHighlightedText( + text: message.body, + font: .system(size: 13) + ) + .multilineTextAlignment(.leading) } private var replyActionTitle: String { @@ -692,3 +696,19 @@ private struct MessagingBubble: View { return "Reply in thread" } } + +private struct MessagingBubbleChrome: ViewModifier { + let direction: MessagingMessageDirection + + func body(content: Content) -> some View { + content + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(direction == .outbound + ? Color.black.opacity(0.08) + : Color.white) + ) + } +} diff --git a/ui/ui/Messaging/MessagingStore.swift b/ui/ui/Messaging/MessagingStore.swift index 19977b6e..cd60e012 100644 --- a/ui/ui/Messaging/MessagingStore.swift +++ b/ui/ui/Messaging/MessagingStore.swift @@ -1,3 +1,4 @@ +import AppKit import Combine import DBRepository import Foundation @@ -26,13 +27,15 @@ final class MessagingStore: ObservableObject { let session: MessagingSessionStore @Published private(set) var isConnectorSyncing = false @Published var isSending = false - @Published var sendToAgent = true - @Published var selectedProfileHandle: String = AgentProfileHandle.orchestrator + @Published private(set) var inboundBanner: String? private var repository: DBRepository? private var cancellables = Set() private lazy var connectorRuntime = ConnectorMessagingRuntime() private var inboundObserver: DerrickDarwinNotifyObserver? + private var knownInboundMessageIDs: Set = [] + private var inboundIDsPrimed = false + private var inboundBannerTask: Task? init() { catalog = MessagingCatalogStore() @@ -151,6 +154,15 @@ final class MessagingStore: ObservableObject { func setWorkspaceActive(_ active: Bool) { session.setWorkspaceActive(active) + publishForegroundPresence() + } + + func publishForegroundPresence(isFrontmost: Bool? = nil) { + DerrickMessagingForegroundPresence.sync( + isMessagingWorkspace: session.isMessagingWorkspace, + pluginID: session.selectedPluginID, + isFrontmost: isFrontmost ?? NSApp.isActive + ) } func syncConnectorsFromFactory() async { @@ -186,6 +198,8 @@ final class MessagingStore: ObservableObject { } await session.openConnector(pluginID: pluginID, autoOpenMostRecent: true) guard session.selectedPluginID == pluginID else { return false } + publishForegroundPresence() + primeInboundMessageIDs() if repository != nil, catalog.contains(pluginID: pluginID) { setConnectorSyncing(true) Task { @MainActor in @@ -217,7 +231,12 @@ final class MessagingStore: ObservableObject { !parent.isEmpty { await session.openReplyThread(parentVendorMessageID: parent) } - return session.selectedPluginID == pluginID && session.selectedThreadID == threadID + let opened = session.selectedPluginID == pluginID && session.selectedThreadID == threadID + if opened { + publishForegroundPresence() + primeInboundMessageIDs() + } + return opened } func updateCredentials(pluginID: String) async -> Bool { @@ -244,10 +263,6 @@ final class MessagingStore: ObservableObject { } func sendMessage(_ text: String, parentVendorMessageID: String? = nil) async { - if sendToAgent { - await sendAgentMessage(text, parentVendorMessageID: parentVendorMessageID) - return - } guard let repository, let pluginID = selectedPluginID, let thread = selectedThread else { @@ -287,56 +302,6 @@ final class MessagingStore: ObservableObject { } } - func sendAgentMessage(_ text: String, parentVendorMessageID: String? = nil) async { - guard let repository, - let pluginID = selectedPluginID, - let thread = selectedThread else { - return - } - guard let resolved = AgentProfileStore.shared.resolveProfile( - explicitHandle: selectedProfileHandle, - message: text - ) else { - session.setLastError("Choose a profile and enter a message. Use $handle to override the profile.") - return - } - let (profile, prompt) = resolved - isSending = true - defer { isSending = false } - do { - try await MessagingAgentRunner.runAndRelay( - prompt: prompt, - profile: profile, - pluginID: pluginID, - thread: thread, - parentVendorMessageID: parentVendorMessageID, - connectorRuntime: connectorRuntime, - repository: repository, - store: self, - session: session - ) - session.setLastError(nil) - } catch { - let detail = error.localizedDescription - session.setLastError(detail) - Task { - await ServiceLogRecorder.shared.record( - service: "messaging", - level: .error, - code: "agent_send_failed", - message: "Messaging agent send failed pluginID=\(pluginID) profile=\(profile.handle): \(detail)", - detailJSON: Self.messagingDetailJSON( - pluginID: pluginID, - threadID: thread.id, - vendorThreadID: thread.vendorThreadID, - text: prompt, - error: detail - ) - ) - } - } - } - /// Send-only connectors: create a thread row for the destination ID, then send. func sendMessage(toChannel vendorThreadID: String, text: String) async { guard let repository, let pluginID = selectedPluginID else { return } @@ -413,11 +378,13 @@ final class MessagingStore: ObservableObject { } await session.selectThread(id: thread.id) session.setLastError(nil) + primeInboundMessageIDs() DerrickMessagingIngressSignal.postPoll() } func selectThread(id: String) async { await session.selectThread(id: id) + primeInboundMessageIDs() } func openReplyThread(parentVendorMessageID: String) async { @@ -432,6 +399,7 @@ final class MessagingStore: ObservableObject { session: session ) session.setLastError(nil) + primeInboundMessageIDs() } catch { let mapped = ConnectorReplyThreadAccessMessage.userFacing( fromVendorDetail: error.localizedDescription @@ -475,6 +443,53 @@ final class MessagingStore: ObservableObject { } await session.markVisibleConversationRead() await catalog.refreshBadges() + presentInboundBannerIfNeeded() + } + + private func currentInboundMessageIDs() -> Set { + Set( + (session.visibleMessages + session.visibleReplyMessages) + .filter { $0.direction == .inbound } + .map(\.id) + ) + } + + private func primeInboundMessageIDs() { + knownInboundMessageIDs = currentInboundMessageIDs() + inboundIDsPrimed = true + } + + private func presentInboundBannerIfNeeded() { + let current = currentInboundMessageIDs() + defer { knownInboundMessageIDs = current } + guard inboundIDsPrimed else { + inboundIDsPrimed = true + return + } + guard session.isMessagingWorkspace else { return } + let newIDs = current.subtracting(knownInboundMessageIDs) + guard !newIDs.isEmpty else { return } + let newest = (session.visibleReplyMessages + session.visibleMessages) + .last { newIDs.contains($0.id) && $0.direction == .inbound } + guard let newest else { return } + let sender = newest.sender.trimmingCharacters(in: .whitespacesAndNewlines) + let body = newest.body.trimmingCharacters(in: .whitespacesAndNewlines) + let preview = body.count > 140 ? String(body.prefix(139)) + "…" : body + let text: String + if sender.isEmpty { + text = preview + } else if preview.isEmpty { + text = sender + } else { + text = "\(sender): \(preview)" + } + inboundBannerTask?.cancel() + inboundBanner = text + inboundBannerTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard !Task.isCancelled else { return } + inboundBanner = nil + } } private static func messagingDetailJSON( diff --git a/ui/ui/Services/AgentServiceClient.swift b/ui/ui/Services/AgentServiceClient.swift index a45e72d1..339fb52d 100644 --- a/ui/ui/Services/AgentServiceClient.swift +++ b/ui/ui/Services/AgentServiceClient.swift @@ -103,6 +103,11 @@ public final class AgentServiceClient: @unchecked Sendable { return report } catch { lastError = error + if let bootstrap = error as? AgentServiceClientError, + case .bootstrapFailed(let message) = bootstrap, + Self.isPermanentBootstrapFailure(message: message) { + throw error + } await MainActor.run { debugLog("AgentService ensure-up attempt \(attempt + 1) failed: \(error.localizedDescription)") } @@ -113,6 +118,13 @@ public final class AgentServiceClient: @unchecked Sendable { throw lastError ?? AgentServiceClientError.unavailable } + private nonisolated static func isPermanentBootstrapFailure(message: String) -> Bool { + let lower = message.lowercased() + return lower.contains("shared database") + || lower.contains("authorization denied") + || lower.contains("app group entitlements") + } + private func requestBootstrap( using proxy: DerrickDaemonServiceXPC ) async throws -> DerrickDaemonBootstrapResult { diff --git a/ui/ui/Views/AgentProfileSettingsView.swift b/ui/ui/Views/AgentProfileSettingsView.swift index 63e9211d..5a6f4cfa 100644 --- a/ui/ui/Views/AgentProfileSettingsView.swift +++ b/ui/ui/Views/AgentProfileSettingsView.swift @@ -28,7 +28,7 @@ struct AgentProfileSettingsView: View { Text("Agent profiles") .font(.system(size: 26, weight: .semibold, design: .rounded)) - Text("Profiles define how Derrick behaves when you message an agent from connectors. Address a profile with $handle (for example $orchestrator). Replies are posted as [\(DerrickAppSupport.hostAppProductName)].") + Text("Profiles define how Derrick behaves when you message an agent from connectors. Start a message with $ and the profile’s short name, like $orchestrator. Replies are posted as [\(DerrickAppSupport.hostAppProductName):orchestrator].") .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -44,7 +44,7 @@ struct AgentProfileSettingsView: View { Text(profile.displayName) Text("$" + profile.handle) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(AgentProfileTokenColor.darkGreen) } Spacer(minLength: 0) if !profile.isEnabled { @@ -107,8 +107,8 @@ struct AgentProfileSettingsView: View { } profileField( - title: "Handle", - caption: "Use as $handle in messages. Letters, numbers, and underscores only." + title: "Short name", + caption: "Put $ in front of this name in messages, like $orchestrator. Letters, numbers, and underscores only." ) { TextField("reviewer", text: $draftHandle) .textFieldStyle(.roundedBorder) diff --git a/ui/ui/Views/ContentView.swift b/ui/ui/Views/ContentView.swift index 9753d1ec..34845a8d 100644 --- a/ui/ui/Views/ContentView.swift +++ b/ui/ui/Views/ContentView.swift @@ -457,6 +457,10 @@ struct ContentView: View { } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in refreshProviderCredentialUI() + messaging.publishForegroundPresence(isFrontmost: true) + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification)) { _ in + messaging.publishForegroundPresence(isFrontmost: false) } .onReceive(NotificationCenter.default.publisher(for: .derrickOpenMessagingConnector)) { notification in guard let pluginID = notification.userInfo?[PluginFactoryTurnNavigation.pluginIDUserInfoKey] as? String, @@ -761,16 +765,16 @@ struct ContentView: View { do { bootstrapStatus.update(phase: .loadingSession, message: "Starting Derrick…") - async let daemonHealth = connectLaunchDaemon() - async let repository = loadLaunchRepository() + // Prewarm Docker in parallel, but connect daemon + DB first so the modal + // does not sit on "Guest runtime ready" while XPC bootstrap is still retrying. async let dockerPeer = prewarmLaunchDockerPeer() - let health = try await daemonHealth + let health = try await connectLaunchDaemon() debugLog( "Daemon ensure-up ok status=\(health.status.rawValue) pid=\(health.pid) runtime=\(health.guestRuntimeImage ?? "?") detail=\(health.detail ?? "")" ) - let repo = try await repository + let repo = try await loadLaunchRepository() if let peer = try await dockerPeer { do { diff --git a/ui/ui/uiApp.swift b/ui/ui/uiApp.swift index b1e32885..2272fc7e 100644 --- a/ui/ui/uiApp.swift +++ b/ui/ui/uiApp.swift @@ -184,6 +184,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { !DerrickNotificationLaunch.hasHITLApprovalPresentationIntent() else { return } DerrickUISessionPresence.clearInteractiveSession() + DerrickMessagingForegroundPresence.clear() DerrickNotificationService.shared.stop() // Do not bootout/kickstart on quit — that kills derrickd and the next // launch hangs on "Connecting to Derrick daemon". diff --git a/ui/uiTests/MessagingNavigationTests.swift b/ui/uiTests/MessagingNavigationTests.swift index 807e3ad0..426c103e 100644 --- a/ui/uiTests/MessagingNavigationTests.swift +++ b/ui/uiTests/MessagingNavigationTests.swift @@ -383,4 +383,10 @@ import Testing #expect(store.lastError == nil) #expect(store.conversationLanding == .vendorConnector(pluginID: "slack-bot")) } + + @Test func profileTokenHighlightMarksDollarHandlesGreenCandidates() { + let text = "$orchestrator tell me about yourself then [Derrick:developer] replied" + let tokens = AgentProfileTokenHighlight.ranges(in: text).map { String(text[$0]) } + #expect(tokens == ["$orchestrator", "developer"]) + } }