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
Expand Up @@ -102,12 +102,15 @@ public nonisolated struct AgentPaneGestureIntent: Equatable, Sendable {
}

/// What a newer reserve on the same connection revokes: one ticket for set_mode, one per
/// config option, and one per prompt id. A rapid later Enter must not revoke an earlier
/// prompt's proof before its frame reaches the relay.
/// config option, one per prompt id and one per permission ask. A rapid later Enter must not
/// revoke an earlier prompt's proof before its frame reaches the relay, nor an answer to a
/// second ask the first ask's answer (that one went out unproved and a remote acpmux refused
/// it, 2026-10-21).
public var slot: String {
switch (method, params["configId"], params["promptId"]) {
case ("session/set_config_option", .string(let config)?, _): return method + "#" + config
case ("session/prompt", _, .string(let promptId)?): return method + "#" + promptId
switch (method, params["configId"], params["promptId"], params["permissionId"]) {
case ("session/set_config_option", .string(let config)?, _, _): return method + "#" + config
case ("session/prompt", _, .string(let promptId)?, _): return method + "#" + promptId
case ("_acpmux/permission_respond", _, _, .string(let permissionId)?): return method + "#" + permissionId
default: return method
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ import CmuxNextCompat
nonisolated static let promptTicketsKept = 8
/// Bound tickets even when a page reserves and never sends them.
nonisolated static let promptTicketsMax = 64
/// The most permission answer tickets one connection keeps (one per ask,
/// ``AgentPaneGestureIntent/slot``): a newer answer drops the oldest beyond this.
nonisolated static let permissionTicketsKept = 8

/// How long an upload that holds a send ticket may go without progress
/// (``uploadProgressed(_:)``) before its hold ends and the ticket with it.
Expand Down Expand Up @@ -192,6 +195,11 @@ import CmuxNextCompat
for old in left.sorted(by: { $0.value.order < $1.value.order }).dropLast(Self.promptTicketsMax - 1) {
dropTickets { key, _ in key == old.key }
}
} else if intent.method == "_acpmux/permission_respond" {
let answers = tickets.filter { $0.value.connection == connection && $0.value.intent.method == intent.method }
for old in answers.sorted(by: { $0.value.order < $1.value.order }).dropLast(Self.permissionTicketsKept - 1) {
dropTickets { key, _ in key == old.key }
}
}
let ticket = UUID().uuidString
issued += 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,50 @@ struct AgentPaneRelayedAllowTests {
"modeId": "m", "answers": ["q": "B"]]]]) == nil)
}

/// 2026-10-21: Leo answered a question card on another machine and acpmux refused it, "allowing
/// a permission is accepted only from the cmux app". A second answer reserved before the first
/// answer's frame went out revoked the first ticket (one slot per method), so the first answer
/// went out unproved. Each ask keeps its own ticket, as each prompt does (#29745).
@Test func answersToTwoAsksReservedBackToBackKeepTheirOwnTickets() throws {
let clock = AgentPaneSlowStartPromptTests.Clock()
let gestures = AgentPaneUserGestures(now: { clock.now })
let firstIntent = try #require(Self.intent(option: "yes", permission: "p1"))
let secondIntent = try #require(Self.intent(option: "yes", permission: "p2"))
func frame(_ permission: String, option: String = "yes") -> [String: Any] {
["sessionId": "s", "permissionId": permission, "optionId": option, "_meta": ["cmuxGesture": "t"]]
}
let method = "_acpmux/permission_respond"

// Two clicks, two asks, before either answer's frame reaches the relay.
gestures.record(click: true)
let first = try #require(gestures.reserve(connection: 1, intent: firstIntent))
gestures.record(click: true)
let second = try #require(gestures.reserve(connection: 1, intent: secondIntent))
#expect(gestures.redeem(first, connection: 1, method: method, params: frame("p1")),
"the second ask's answer revoked the first ask's ticket")
#expect(gestures.redeem(second, connection: 1, method: method, params: frame("p2")))

// A newer answer to the same ask still replaces the older one.
gestures.record(click: true)
let older = try #require(gestures.reserve(connection: 1, intent: firstIntent))
gestures.record(click: true)
let newer = try #require(gestures.reserve(connection: 1, intent: try #require(Self.intent(option: "no", permission: "p1"))))
#expect(!gestures.redeem(older, connection: 1, method: method, params: frame("p1")))
#expect(gestures.redeem(newer, connection: 1, method: method, params: frame("p1", option: "no")))

// Answer tickets stay bounded per connection, the oldest going first.
var reserved: [String] = []
for index in 0..<(AgentPaneUserGestures.permissionTicketsKept + 2) {
gestures.record(click: true)
reserved.append(try #require(gestures.reserve(connection: 1, intent: try #require(Self.intent(option: "yes", permission: "q\(index)")))))
}
#expect(!gestures.redeem(reserved[0], connection: 1, method: method, params: frame("q0")))
#expect(!gestures.redeem(reserved[1], connection: 1, method: method, params: frame("q1")))
#expect(gestures.redeem(reserved[2], connection: 1, method: method, params: frame("q2")))
let last = reserved.count - 1
#expect(gestures.redeem(reserved[last], connection: 1, method: method, params: frame("q\(last)")))
}

/// The same review, through the transport: a ticket the page took for answer B does not let
/// it send answer A to another machine (the gesture is refused, so nothing is signed).
@Test func aTicketForOneAnswerIsRefusedForAnother() async throws {
Expand Down
Loading