`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>
242 lines
9.2 KiB
Swift
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 }
|
|
}
|
|
}
|