Files
Termind/TermindTests/AgentApprovalQueueTests.swift
T
Kaede AkinoandClaude Opus 5 da3082aff8 Ask a group of decisions at once, not one at a time
`request_user_input` took one question. A turn that needed three raised them
one after another, each hidden until the one before it was answered — and a
second question you cannot see is a second question that cannot inform the
first. It now takes up to three, drawn as one form with one Continue.

The earlier message on `2ec0e12` gave the wrong reason for not doing this.
It said several questions per call "would need a queue and a
multi-question card", as though the runtime's single `pendingUserInput`
forbade it. It never did: a call carrying three questions is still one
blocking request and one continuation, which is exactly what a queue is
not. That was a preference dressed as a constraint. The real cost is the
two things below — per-question drafts, and a form that can outgrow its
strip — and neither was a reason to refuse.

The queue that *did* arrive in `22258e3` does not cover it either, for a
reason worth writing down. `request_user_input` is `.sequential`, and a
batch runs in parallel only when every call in it is, so two questions in
one turn execute one after the other and `pendingUserInputs` never holds
more than one. The queue and its `1 of N` are live for approvals and
purely defensive for questions. Sequential is right for this tool —
racing a question against other calls means output arriving under
something still being read — so batching inside one call is the only way
several questions reach a person together. The queue stays as it is.

A question may be left unanswered, and the tool reports that as a null
rather than an error. That needs saying in the tool's own description or
a model reads the null as a failure and asks the same thing again, which
puts the user in a loop they answered their way out of once already. So
the description now says plainly: a null is the user declining to decide
that one — choose, say which assumption you used, and do not ask again.
Continue needs one answer, not all of them; it needs at least one because
answering nothing is Cancel, and reporting that as success would hand the
model a result whose only content is that it was not refused.

Drafts become per question. Sharing one text field and one selection set
across a form meant typing into the third question cleared the first, and
the mutual exclusion between an option and a typed answer — which is
per-question by definition — reached across all of them. `AgentUserQuestion`
carries the id the model chose, so answers travel under the model's own
names rather than an index we assign, and every question is reported back
whether answered or not: an absent key would read as a bug where a null
reads as a decision.

Two things fell out of rendering it. A bare `ScrollView` capped at the
form's maximum height is greedy, so a one-question card was handed 320pt
of box for 120pt of question; `ViewThatFits` prefers the natural stack and
falls back to scrolling only when the questions genuinely overflow. And
`reported` is not private, because that shape is the contract with the
model and a test should be able to hold it still — including that a
secret, which `request_user_input` never asks for, is reported as skipped
rather than echoed if one ever arrives there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:52:21 +08:00

242 lines
9.2 KiB
Swift

import Foundation
import Testing
@testable import Termind
private actor CallLog {
private(set) var started: [String] = []
private(set) var finished: [String] = []
func start(_ name: String) { started.append(name) }
func finish(_ name: String) { finished.append(name) }
}
private actor QueueScriptedModel: AgentModelStreaming {
private let turns: [[AgentModelStreamEvent]]
private var index = 0
init(_ turns: [[AgentModelStreamEvent]]) { self.turns = turns }
private func next() -> [AgentModelStreamEvent] {
defer { index += 1 }
return turns[min(index, turns.count - 1)]
}
nonisolated func stream(
_ request: AgentModelRequest
) -> AsyncThrowingStream<AgentModelStreamEvent, any Error> {
AsyncThrowingStream { continuation in
Task {
for event in await next() { continuation.yield(event) }
continuation.finish()
}
}
}
}
private let queueSchema = AgentJSONValue.object([
"type": .string("object"), "properties": .object([:]),
"required": .array([]), "additionalProperties": .bool(false),
])
private func turnsCalling(_ names: [String]) -> [[AgentModelStreamEvent]] {
[
names.enumerated().map { index, name in
.toolCallSnapshot(
id: "call-\(index)", providerItemID: nil, name: name, arguments: "{}"
)
} + [.finished(.toolCalls)],
[.textDelta("done"), .finished(.completed)],
]
}
/// Requires authorization and may run beside its peers — the combination the
/// single pending-approval slot could not survive.
private func gatedTool(_ name: String, log: CallLog) -> AnyAgentTool {
AnyAgentTool(descriptor: AgentToolDescriptor(
name: name, summary: name, inputSchema: queueSchema,
target: .host, safety: .requiresAuthorization, concurrency: .parallel
)) { invocation, _ in
await log.start(name)
await log.finish(name)
return AgentToolResult(callID: invocation.call.id, content: name)
}
}
/// Never returns on its own; only cancellation ends it.
private func hangingTool(_ name: String, log: CallLog) -> AnyAgentTool {
AnyAgentTool(descriptor: AgentToolDescriptor(
name: name, summary: name, inputSchema: queueSchema,
target: .local, safety: .locallyReadOnly, concurrency: .parallel
)) { invocation, _ in
await log.start(name)
try await Task.sleep(for: .seconds(30))
return AgentToolResult(callID: invocation.call.id, content: name)
}
}
@Suite
struct AgentApprovalQueueTests {
/// The bug this replaces was a deadlock, not a cosmetic one: two calls
/// needing approval in the same turn overwrote the single pending slot, and
/// the first request's continuation was never resumed. The run stopped with
/// nothing on screen to answer and no error to report.
@MainActor
@Test
func twoApprovalsInOneTurnQueueAndAreBothAnswered() async {
let log = CallLog()
let conversation = ConversationModel()
let runtime = AgentRuntime(
model: QueueScriptedModel(turnsCalling(["first", "second"])),
tools: [gatedTool("first", log: log), gatedTool("second", log: log)],
approval: AgentApprovalBroker(
reviewer: nil,
manualApproval: { [weak conversation] request in
guard let conversation else { return .deny("gone") }
return await conversation.requestApproval(request)
}
)
)
conversation.start(runtime: runtime, request: AgentRunRequest(
conversationID: conversation.id, prompt: "go", permissionMode: .askForApproval
))
await wait { conversation.pendingApprovals.count == 2 }
// Both are waiting, but only the head is presented: authorizing three
// things while reading one is not a decision anybody made.
#expect(conversation.pendingApproval?.id == conversation.pendingApprovals.first?.id)
#expect(conversation.runState == .waitingForApproval)
conversation.respondToApproval(allow: true)
await wait { conversation.pendingApprovals.count == 1 }
conversation.respondToApproval(allow: true)
await waitForTerminal(conversation)
#expect(await log.finished.sorted() == ["first", "second"])
#expect(conversation.runState == .completed)
#expect(conversation.pendingApprovals.isEmpty)
}
@MainActor
@Test
func denyingTheHeadLeavesTheNextRequestAnswerable() async {
let log = CallLog()
let conversation = ConversationModel()
let runtime = AgentRuntime(
model: QueueScriptedModel(turnsCalling(["denied", "allowed"])),
tools: [gatedTool("denied", log: log), gatedTool("allowed", log: log)],
approval: AgentApprovalBroker(
reviewer: nil,
manualApproval: { [weak conversation] request in
guard let conversation else { return .deny("gone") }
return await conversation.requestApproval(request)
}
)
)
conversation.start(runtime: runtime, request: AgentRunRequest(
conversationID: conversation.id, prompt: "go", permissionMode: .askForApproval
))
await wait { conversation.pendingApprovals.count == 2 }
conversation.respondToApproval(allow: false)
await wait { conversation.pendingApprovals.count == 1 }
conversation.respondToApproval(allow: true)
await waitForTerminal(conversation)
#expect(await log.finished == ["allowed"])
#expect(conversation.toolCards.contains { $0.state == .denied })
// Both calls are still answered, one with the denial, so the turn stays
// replayable.
#expect(conversation.transcript.filter { $0.role == .tool }.count == 2)
}
/// Run state is derived from what is outstanding rather than asserted by
/// whichever tool moved last, so one answered question cannot unlock the
/// composer over another that is still open.
@MainActor
@Test
func runStateFollowsTheDeepestOutstandingWait() async {
let conversation = ConversationModel()
conversation.handle(.runState(.running))
#expect(conversation.runState == .running)
let invocation = AgentToolInvocation(
runID: UUID(),
call: AgentToolCall(id: "1", name: "review", arguments: .object([:]))
)
let request = AgentApprovalRequest(
invocation: invocation,
descriptor: AgentToolDescriptor(
name: "review", summary: "review", inputSchema: queueSchema,
target: .host, safety: .requiresAuthorization
),
userIntent: "go"
)
conversation.handle(.reviewStarted(request))
#expect(conversation.runState == .reviewing)
conversation.present(
AgentUserInputRequest(
questions: [AgentUserQuestion(id: "host", prompt: "which host?", kind: .text)]
)
)
#expect(conversation.runState == .waitingForUser)
// The review finishing must not announce that nothing is outstanding.
conversation.handle(.reviewFinished(request, SecurityReviewDecision(
verdict: .approve, risk: .low, userAuthorization: .sufficient, reason: "ok"
)))
#expect(conversation.runState == .waitingForUser)
conversation.cancelUserInput()
#expect(conversation.runState == .running)
}
/// Cancelling mid-batch must still answer every call. An assistant turn
/// with unanswered tool calls is rejected by every provider on the *next*
/// request, so a stopped run would otherwise become an unusable
/// conversation rather than an interrupted one.
@Test
func cancellingAParallelBatchStillAnswersEveryCall() async {
let log = CallLog()
let runtime = AgentRuntime(
model: QueueScriptedModel(turnsCalling(["a", "b"])),
tools: [hangingTool("a", log: log), hangingTool("b", log: log)],
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow })
)
let stream = await runtime.start(AgentRunRequest(
conversationID: UUID(), prompt: "go", permissionMode: .askForApproval
))
let collector = Task {
var events: [AgentEvent] = []
for await event in stream { events.append(event) }
return events
}
for _ in 0..<400 where await log.started.count < 2 {
try? await Task.sleep(for: .milliseconds(5))
}
await runtime.cancel()
let events = await collector.value
let finished = events.filter { if case .toolFinished = $0 { true } else { false } }
#expect(finished.count == 2, "every call in the batch needs a result")
#expect(events.contains(.runState(.cancelled)))
}
@MainActor
private func wait(for condition: @MainActor () -> Bool) async {
for _ in 0..<400 {
if condition() { return }
try? await Task.sleep(for: .milliseconds(5))
}
}
@MainActor
private func waitForTerminal(_ conversation: ConversationModel) async {
await wait { conversation.runState.isTerminal }
}
}