diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AgentDialogLine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AgentDialogLine.swift new file mode 100644 index 000000000000..059c8addb6a8 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AgentDialogLine.swift @@ -0,0 +1,88 @@ +import Foundation + +/// One short connection to this app's acpmux that the app's dialog desk (`feed.list`, +/// `feed.permission.reply`, `feed.question.reply`) reads and answers on. The app's is +/// ``AcpmuxPersonLine`` (proven as the person); tests pass a fake. +@MainActor +public protocol AgentDialogLine: AnyObject { + /// acpmux took this connection's person proof. + var isPerson: Bool { get } + /// The surfaces this acpmux serves (`initialize`'s `_meta.acpmux.capabilities`). + var capabilities: Set { get } + /// One request; its `result`, or nil when it failed or the connection closed. + func call(_ method: String, _ params: [String: Any]) async -> [String: Any]? + func close() +} + +public extension AgentDialogLine { + var capabilities: Set { [] } +} + +extension AcpmuxPersonLine: AgentDialogLine {} + +/// One pending prompt as acpmux reports it (``AgentDialogLine/pendingPrompts()``). +public struct AgentDialogPrompt { + public let session: String + public let sessionName: String? + public let permission: String + public let request: [String: Any] + + /// The acpmux capability of `_acpmux/sessions {pendingOnly: true}`. + public static let pendingListCapability = "session-list-pending-v1" +} + +public extension AgentDialogLine { + /// Every session's pending prompts: `_acpmux/sessions`, then `_acpmux/info` for each session + /// with one. Empty when the session list fails. + func pendingPrompts() async -> [AgentDialogPrompt] { + let sessions: [[String: Any]] + // `pendingOnly`: only the chats with an ask, never paced (acpmux `list_reply.rs`). Paging + // the whole list took 4-9 s a page on a host mirroring 8,500 chats, past the 5 s line. + if capabilities.contains(AgentDialogPrompt.pendingListCapability) { + guard let rows = (await call("_acpmux/sessions", ["pendingOnly": true]))?["sessions"] as? [[String: Any]] else { return [] } + sessions = rows + } else { + guard let rows = await allSessions() else { return [] } + sessions = rows + } + return await prompts(of: sessions) + } + + /// Every session, page by page, from an acpmux without `pendingOnly`; nil on a failed page. + private func allSessions() async -> [[String: Any]]? { + var sessions: [[String: Any]] = [] + var cursor: String? + var seen: Set = [] + var seenSessions: Set = [] + while true { // wakeup-allow: each pass awaits a daemon page; a terminal page, malformed reply, or repeated cursor ends it + var params: [String: Any] = ["limit": 64] + if let cursor { params["cursor"] = cursor } + guard let page = await call("_acpmux/sessions", params) else { return nil } + guard let pageRows = page["sessions"] as? [[String: Any]] else { return nil } + for row in pageRows { + guard let id = row["sessionId"] as? String, !id.isEmpty else { continue } + let peer = row["peer"] as? String ?? "" + let key = "\(peer)/\(id)" + if seenSessions.insert(key).inserted { sessions.append(row) } + } + guard let next = (page["nextCursor"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else { break } + guard seen.insert(next).inserted else { return nil } + cursor = next + } + return sessions + } + + /// The pending prompts of each listed session that reports one (`_acpmux/info`). + private func prompts(of sessions: [[String: Any]]) async -> [AgentDialogPrompt] { + var out: [AgentDialogPrompt] = [] + for summary in sessions where (summary["pendingPermissions"] as? NSNumber)?.intValue ?? 0 > 0 { + guard let session = summary["sessionId"] as? String else { continue } + let entries = (await call("_acpmux/info", ["sessionId": session]))?["pending"] as? [[String: Any]] ?? [] + for entry in entries { + guard let permission = entry["permissionId"] as? String, let request = entry["request"] as? [String: Any] else { continue } + out.append(AgentDialogPrompt(session: session, sessionName: summary["name"] as? String, permission: permission, request: request)) + } + } + return out + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AgentDialogDesk.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AgentDialogDesk.swift index bd92f2f06527..bcb2cc92c22f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AgentDialogDesk.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AgentDialogDesk.swift @@ -35,21 +35,7 @@ final class AgentDialogDesk { } /// One pending prompt as acpmux reports it. - struct Pending { - let session: String - let sessionName: String? - let permission: String - let request: [String: Any] - - /// The prompt as a question this socket may answer; nil for a tool permission. - var question: AgentQuestion? { - guard let data = try? JSONSerialization.data(withJSONObject: request), - let json = try? AgentQuestionJSON(data: data), - let question = AgentQuestion(permissionRequest: json, permissionId: permission, session: session), - question.source.harness != .acp else { return nil } - return question - } - } + typealias Pending = AgentDialogPrompt // MARK: feed.list @@ -58,67 +44,12 @@ final class AgentDialogDesk { var items: [JSONValue] = [] if let line = await open() { defer { line.close() } - items = await pending(on: line).map { item($0) } + items = await line.pendingPrompts().map { item($0) } } if !pendingOnly { items += answered.reversed() } return .object(["items": .array(items)]) } - private func pending(on line: any AgentDialogLine) async -> [Pending] { - let sessions: [[String: Any]] - // `pendingOnly`: only the chats with an ask, never paced (acpmux `list_reply.rs`). Paging - // the whole list took 4-9 s a page on a host mirroring 8,500 chats, past the 5 s line. - if line.capabilities.contains(Self.pendingListCapability) { - guard let rows = (await line.call("_acpmux/sessions", ["pendingOnly": true]))?["sessions"] as? [[String: Any]] else { return [] } - sessions = rows - } else { - guard let rows = await allSessions(on: line) else { return [] } - sessions = rows - } - return await prompts(of: sessions, on: line) - } - - /// The acpmux capability of `_acpmux/sessions {pendingOnly: true}`. - static let pendingListCapability = "session-list-pending-v1" - - /// Every session, page by page, from an acpmux without `pendingOnly`; nil on a failed page. - private func allSessions(on line: any AgentDialogLine) async -> [[String: Any]]? { - var sessions: [[String: Any]] = [] - var cursor: String? - var seen: Set = [] - var seenSessions: Set = [] - while true { // wakeup-allow: each pass awaits a daemon page; a terminal page, malformed reply, or repeated cursor ends it - var params: [String: Any] = ["limit": 64] - if let cursor { params["cursor"] = cursor } - guard let page = await line.call("_acpmux/sessions", params) else { return nil } - guard let pageRows = page["sessions"] as? [[String: Any]] else { return nil } - for row in pageRows { - guard let id = row["sessionId"] as? String, !id.isEmpty else { continue } - let peer = row["peer"] as? String ?? "" - let key = "\(peer)/\(id)" - if seenSessions.insert(key).inserted { sessions.append(row) } - } - guard let next = (page["nextCursor"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else { break } - guard seen.insert(next).inserted else { return nil } - cursor = next - } - return sessions - } - - /// The pending prompts of each listed session that reports one (`_acpmux/info`). - private func prompts(of sessions: [[String: Any]], on line: any AgentDialogLine) async -> [Pending] { - var out: [Pending] = [] - for summary in sessions where (summary["pendingPermissions"] as? NSNumber)?.intValue ?? 0 > 0 { - guard let session = summary["sessionId"] as? String else { continue } - let entries = (await line.call("_acpmux/info", ["sessionId": session]))?["pending"] as? [[String: Any]] ?? [] - for entry in entries { - guard let permission = entry["permissionId"] as? String, let request = entry["request"] as? [String: Any] else { continue } - out.append(Pending(session: session, sessionName: summary["name"] as? String, permission: permission, request: request)) - } - } - return out - } - private func item(_ prompt: Pending, status: String = "pending") -> JSONValue { let call = prompt.request["toolCall"] as? [String: Any] ?? [:] let title = (call["title"] as? String ?? "").secretsScrubbed.acpmuxVisible.acpmuxCut(500) @@ -260,7 +191,7 @@ final class AgentDialogDesk { guard let line = await open() else { throw ControlError(code: "unavailable", message: Self.text("agentDialogs.noAcpmux", "No agent daemon (acpmux) answers in this app")) } - guard let prompt = await pending(on: line).first(where: { $0.permission == id }) else { + guard let prompt = await line.pendingPrompts().first(where: { $0.permission == id }) else { line.close() throw ControlError(code: "not_found", message: Self.text("agentDialogs.notFound", "No pending dialog %@; it may have been answered or ended", id), data: ["request_id": .string(id)]) @@ -289,6 +220,17 @@ final class AgentDialogDesk { } } +extension AgentDialogPrompt { + /// The prompt as a question this socket may answer; nil for a tool permission. + var question: AgentQuestion? { + guard let data = try? JSONSerialization.data(withJSONObject: request), + let json = try? AgentQuestionJSON(data: data), + let question = AgentQuestion(permissionRequest: json, permissionId: permission, session: session), + question.source.harness != .acp else { return nil } + return question + } +} + private extension JSONValue { /// The text, or null. static func orNull(_ text: String?) -> JSONValue { text.map(JSONValue.string) ?? .null } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AgentDialogLine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AgentDialogLine.swift deleted file mode 100644 index b13e729efe54..000000000000 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AgentDialogLine.swift +++ /dev/null @@ -1,21 +0,0 @@ -import CmuxNextAgentPane -import Foundation - -/// One short connection to this app's acpmux that ``AgentDialogDesk`` reads and answers on. The -/// app's is ``AcpmuxPersonLine`` (proven as the person); tests pass a fake. -@MainActor -protocol AgentDialogLine: AnyObject { - /// acpmux took this connection's person proof. - var isPerson: Bool { get } - /// The surfaces this acpmux serves (`initialize`'s `_meta.acpmux.capabilities`). - var capabilities: Set { get } - /// One request; its `result`, or nil when it failed or the connection closed. - func call(_ method: String, _ params: [String: Any]) async -> [String: Any]? - func close() -} - -extension AgentDialogLine { - var capabilities: Set { [] } -} - -extension AcpmuxPersonLine: AgentDialogLine {} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentDialogControlTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentDialogControlTests.swift index f0837061f110..54668b166a74 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentDialogControlTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentDialogControlTests.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxNextAgentPane @testable import CmuxNextApp import CmuxNextControl import CmuxNextSettings @@ -41,6 +42,13 @@ import Testing func call(_ method: String, _ params: [String: Any]) async -> [String: Any]? { sent.append((method, params)) switch method { + case "_acpmux/sessions" where params["pendingOnly"] as? Bool == true: + // acpmux `pending_session_summaries`: each session with an ask once, in one reply. + return ["sessions": [ + ["sessionId": "s-tool", "name": "builder", "pendingPermissions": 1], + ["sessionId": "offline", "name": "peer", "peer": "gcp", "pendingPermissions": 1], + ["sessionId": "s-ask", "name": "asker", "pendingPermissions": 1], + ]] case "_acpmux/sessions": return ["sessions": [ ["sessionId": "s-idle", "name": "idle", "pendingPermissions": 0], @@ -110,7 +118,7 @@ import Testing /// paging every chat took 4-9 s a page on a host mirroring 8,500, past the 5 s line. @Test func listAsksAnAcpmuxWithPendingOnlyForTheChatsWithAnAskOnce() async throws { let line = FakeLine() - line.capabilities = [AgentDialogDesk.pendingListCapability] + line.capabilities = [AgentDialogPrompt.pendingListCapability] let reply = try await call(router(desk(line)), "feed.list", ["pending_only": true]).get() #expect(reply["items"]?.arrayValue?.count == 2) let lists = line.sent.filter { $0.method == "_acpmux/sessions" } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentLimitTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentLimitTests.swift index 71f16cfd46ab..339b9ec7136f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentLimitTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentLimitTests.swift @@ -1,4 +1,5 @@ import CmuxNextActions +import CmuxNextAgentPane @testable import CmuxNextApp import CmuxNextDesign import Foundation