Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String> { 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<String> { [] }
}

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<String> = []
var seenSessions: Set<String> = []
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<String> = []
var seenSessions: Set<String> = []
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)
Expand Down Expand Up @@ -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)])
Expand Down Expand Up @@ -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 }
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import CmuxNextAgentPane
@testable import CmuxNextApp
import CmuxNextControl
import CmuxNextSettings
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CmuxNextActions
import CmuxNextAgentPane
@testable import CmuxNextApp
import CmuxNextDesign
import Foundation
Expand Down
Loading