`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>
518 lines
20 KiB
Swift
518 lines
20 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import Termind
|
|
|
|
@MainActor
|
|
@Suite
|
|
struct AgentUserInputTests {
|
|
|
|
// MARK: - Parsing the tool's questions
|
|
|
|
@Test
|
|
func questionsCarryTheirOptionsDescriptionsAndRecommendations() throws {
|
|
let questions = try arguments([
|
|
"questions": .array([
|
|
.object([
|
|
"id": .string("storage"),
|
|
"header": .string("Storage"),
|
|
"prompt": .string("Which database?"),
|
|
"options": .array([
|
|
.object([
|
|
"id": .string("postgres"), "label": .string("Postgres"),
|
|
"description": .string("JSONB, and it matches the existing schema."),
|
|
"recommended": .bool(true),
|
|
]),
|
|
.object(["id": .string("sqlite"), "label": .string("SQLite")]),
|
|
]),
|
|
]),
|
|
.object(["id": .string("base_url"), "prompt": .string("Which URL?")]),
|
|
])
|
|
]).userQuestions()
|
|
|
|
#expect(questions.count == 2)
|
|
#expect(questions[0].header == "Storage")
|
|
#expect(questions[0].options.count == 2)
|
|
#expect(questions[0].options[0].detail == "JSONB, and it matches the existing schema.")
|
|
#expect(questions[0].options[0].isRecommended)
|
|
// Absent is absent, not false-y noise.
|
|
#expect(questions[0].options[1].detail.isEmpty)
|
|
#expect(!questions[0].options[1].isRecommended)
|
|
|
|
// No options is a free-form question, not an empty menu.
|
|
guard case .text = questions[1].kind else {
|
|
Issue.record("expected a free-form question, got \(questions[1].kind)")
|
|
return
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func choiceFlagsDefaultToOfferingAWayOut() throws {
|
|
let questions = try arguments([
|
|
"questions": .array([
|
|
.object([
|
|
"id": .string("q"), "prompt": .string("?"),
|
|
"options": .array([.object(["id": .string("a"), "label": .string("A")])]),
|
|
])
|
|
])
|
|
]).userQuestions()
|
|
|
|
guard case .choice(let choice) = questions[0].kind else {
|
|
Issue.record("expected a choice question")
|
|
return
|
|
}
|
|
#expect(choice.allowsCustomAnswer, "a way out has to be the default")
|
|
#expect(!choice.allowsMultipleAnswers)
|
|
}
|
|
|
|
/// Everything malformed is rejected rather than dropped: a form silently
|
|
/// missing the question the user needed is worse than an error the model
|
|
/// can retry.
|
|
@Test
|
|
func malformedQuestionsAreRejectedRatherThanDropped() {
|
|
let bad: [[String: AgentJSONValue]] = [
|
|
[:],
|
|
["questions": .array([])],
|
|
["questions": .object([:])],
|
|
["questions": .array([.object(["prompt": .string("no id")])])],
|
|
["questions": .array([.object(["id": .string("q")])])],
|
|
["questions": .array([
|
|
.object(["id": .string("q"), "prompt": .string("first")]),
|
|
.object(["id": .string("q"), "prompt": .string("duplicate id")]),
|
|
])],
|
|
["questions": .array([
|
|
.object([
|
|
"id": .string("q"), "prompt": .string("?"),
|
|
"options": .array([
|
|
.object(["id": .string("a"), "label": .string("A")]),
|
|
.object(["id": .string("a"), "label": .string("Also A")]),
|
|
]),
|
|
])
|
|
])],
|
|
["questions": .array([.object([
|
|
"id": .string("q"), "prompt": .string("?"),
|
|
"options": .array([.string("a bare string")]),
|
|
])])],
|
|
]
|
|
for object in bad {
|
|
#expect(throws: AgentToolError.self) { try arguments(object).userQuestions() }
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func askingMoreThanThreeQuestionsAtOnceIsRefused() {
|
|
let four = (1...4).map { index in
|
|
AgentJSONValue.object([
|
|
"id": .string("q\(index)"), "prompt": .string("Question \(index)?"),
|
|
])
|
|
}
|
|
#expect(throws: AgentToolError.self) {
|
|
try arguments(["questions": .array(four)]).userQuestions()
|
|
}
|
|
#expect(throws: Never.self) {
|
|
try arguments(["questions": .array(Array(four.prefix(3)))]).userQuestions()
|
|
}
|
|
}
|
|
|
|
// MARK: - One question
|
|
|
|
@Test
|
|
func nothingIsPreselectedEvenWhenAnOptionIsRecommended() {
|
|
let conversation = ConversationModel()
|
|
conversation.present(request(multiple: false))
|
|
|
|
#expect(conversation.userInputDraft(for: "storage").selection.isEmpty)
|
|
// A recommendation is a label, not a default — Continue stays off until
|
|
// the user actually answers.
|
|
#expect(!conversation.canSubmitUserInput)
|
|
}
|
|
|
|
@Test
|
|
func choosingAnOptionInASingleAnswerQuestionClearsATypedOne() {
|
|
let conversation = ConversationModel()
|
|
conversation.present(request(multiple: false))
|
|
|
|
conversation.setUserInputCustomAnswer("MySQL", for: "storage")
|
|
#expect(conversation.canSubmitUserInput)
|
|
|
|
conversation.toggleUserInputOption("postgres", in: "storage")
|
|
#expect(conversation.userInputDraft(for: "storage").selection == ["postgres"])
|
|
#expect(conversation.userInputDraft(for: "storage").text.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func typingInASingleAnswerQuestionClearsTheChosenOption() {
|
|
let conversation = ConversationModel()
|
|
conversation.present(request(multiple: false))
|
|
|
|
conversation.toggleUserInputOption("postgres", in: "storage")
|
|
conversation.setUserInputCustomAnswer("MySQL", for: "storage")
|
|
|
|
#expect(conversation.userInputDraft(for: "storage").selection.isEmpty)
|
|
#expect(conversation.userInputDraft(for: "storage").text == "MySQL")
|
|
}
|
|
|
|
@Test
|
|
func singleAnswerSelectionReplacesRatherThanAccumulates() {
|
|
let conversation = ConversationModel()
|
|
conversation.present(request(multiple: false))
|
|
|
|
conversation.toggleUserInputOption("postgres", in: "storage")
|
|
conversation.toggleUserInputOption("sqlite", in: "storage")
|
|
#expect(conversation.userInputDraft(for: "storage").selection == ["sqlite"])
|
|
|
|
// Clicking the chosen one again takes the answer back.
|
|
conversation.toggleUserInputOption("sqlite", in: "storage")
|
|
#expect(conversation.userInputDraft(for: "storage").selection.isEmpty)
|
|
}
|
|
|
|
/// When several answers are allowed the two routes are additive, not
|
|
/// exclusive — "these two, plus this other thing" is a real answer.
|
|
@Test
|
|
func severalAnswersMayIncludeATypedOne() {
|
|
let conversation = ConversationModel()
|
|
conversation.present(request(multiple: true))
|
|
|
|
conversation.toggleUserInputOption("postgres", in: "storage")
|
|
conversation.toggleUserInputOption("sqlite", in: "storage")
|
|
conversation.setUserInputCustomAnswer("and DuckDB", for: "storage")
|
|
|
|
#expect(conversation.userInputDraft(for: "storage").selection == ["postgres", "sqlite"])
|
|
#expect(conversation.userInputDraft(for: "storage").text == "and DuckDB")
|
|
}
|
|
|
|
// MARK: - Several questions at once
|
|
|
|
@Test
|
|
func eachQuestionKeepsItsOwnAnswer() {
|
|
let conversation = ConversationModel()
|
|
conversation.present(threeQuestions())
|
|
|
|
conversation.toggleUserInputOption("postgres", in: "storage")
|
|
conversation.toggleUserInputOption("flyway", in: "migrations")
|
|
conversation.setUserInputCustomAnswer("staging-2", for: "host")
|
|
|
|
#expect(conversation.userInputDraft(for: "storage").selection == ["postgres"])
|
|
#expect(conversation.userInputDraft(for: "migrations").selection == ["flyway"])
|
|
#expect(conversation.userInputDraft(for: "storage").text.isEmpty)
|
|
#expect(conversation.userInputDraft(for: "host").text == "staging-2")
|
|
#expect(conversation.answeredUserInputCount == 3)
|
|
}
|
|
|
|
/// Single- and multi-answer questions sit in the same form and keep their
|
|
/// own rule; the mutual exclusion of one must not reach into the other.
|
|
@Test
|
|
func exclusionRulesApplyPerQuestionNotPerForm() {
|
|
let conversation = ConversationModel()
|
|
conversation.present(threeQuestions())
|
|
|
|
conversation.toggleUserInputOption("flyway", in: "migrations")
|
|
conversation.toggleUserInputOption("liquibase", in: "migrations")
|
|
#expect(
|
|
conversation.userInputDraft(for: "migrations").selection == ["flyway", "liquibase"],
|
|
"migrations allows several"
|
|
)
|
|
|
|
conversation.toggleUserInputOption("postgres", in: "storage")
|
|
conversation.toggleUserInputOption("sqlite", in: "storage")
|
|
#expect(
|
|
conversation.userInputDraft(for: "storage").selection == ["sqlite"],
|
|
"storage allows one"
|
|
)
|
|
#expect(
|
|
conversation.userInputDraft(for: "migrations").selection == ["flyway", "liquibase"],
|
|
"and choosing in one question must not disturb another"
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func oneAnswerIsEnoughToContinueButNoneIsNot() {
|
|
let conversation = ConversationModel()
|
|
conversation.present(threeQuestions())
|
|
|
|
#expect(!conversation.canSubmitUserInput, "answering nothing is Cancel, not Continue")
|
|
conversation.toggleUserInputOption("postgres", in: "storage")
|
|
#expect(conversation.canSubmitUserInput)
|
|
#expect(conversation.answeredUserInputCount == 1)
|
|
}
|
|
|
|
// MARK: - What the tool gets back
|
|
|
|
@Test
|
|
func skippedQuestionsAreAbsentFromTheResponse() async {
|
|
let conversation = ConversationModel()
|
|
let answering = Task { await conversation.request(threeQuestions()) }
|
|
await settle(conversation)
|
|
|
|
conversation.toggleUserInputOption("flyway", in: "migrations")
|
|
conversation.submitUserInput()
|
|
|
|
#expect(
|
|
await answering.value
|
|
== .answered([
|
|
AgentUserInputAnswer(
|
|
questionID: "migrations", value: .choice(selectedIDs: ["flyway"], custom: nil)
|
|
)
|
|
])
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func answerReportsIdsInTheOrderTheQuestionListedThem() async {
|
|
let conversation = ConversationModel()
|
|
let answering = Task { await conversation.request(request(multiple: true)) }
|
|
await settle(conversation)
|
|
|
|
// Clicked back to front; the answer still reads front to back, because
|
|
// the model wrote the list and reads the reply against it.
|
|
conversation.toggleUserInputOption("duckdb", in: "storage")
|
|
conversation.toggleUserInputOption("postgres", in: "storage")
|
|
conversation.submitUserInput()
|
|
|
|
#expect(
|
|
await answering.value
|
|
== .answered([
|
|
AgentUserInputAnswer(
|
|
questionID: "storage",
|
|
value: .choice(selectedIDs: ["postgres", "duckdb"], custom: nil)
|
|
)
|
|
])
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func aTypedAnswerComesBackAsCustomTextWithNoSelection() async {
|
|
let conversation = ConversationModel()
|
|
let answering = Task { await conversation.request(request(multiple: false)) }
|
|
await settle(conversation)
|
|
|
|
conversation.setUserInputCustomAnswer(" MySQL ", for: "storage")
|
|
conversation.submitUserInput()
|
|
|
|
// Trimmed, because trailing whitespace is not part of anyone's answer.
|
|
#expect(
|
|
await answering.value
|
|
== .answered([
|
|
AgentUserInputAnswer(
|
|
questionID: "storage", value: .choice(selectedIDs: [], custom: "MySQL")
|
|
)
|
|
])
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func cancellingReportsCancelledRatherThanAnEmptyAnswer() async {
|
|
let conversation = ConversationModel()
|
|
let answering = Task { await conversation.request(threeQuestions()) }
|
|
await settle(conversation)
|
|
|
|
conversation.toggleUserInputOption("postgres", in: "storage")
|
|
conversation.cancelUserInput()
|
|
|
|
#expect(await answering.value == .cancelled)
|
|
#expect(conversation.pendingUserInput == nil)
|
|
#expect(conversation.userInputDraft(for: "storage").selection.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func submittingNothingIsRefusedRatherThanSentAsAnEmptyAnswerList() async {
|
|
let conversation = ConversationModel()
|
|
let answering = Task { await conversation.request(request(multiple: false)) }
|
|
await settle(conversation)
|
|
|
|
conversation.submitUserInput()
|
|
#expect(conversation.pendingUserInput != nil, "the request must still be waiting")
|
|
|
|
conversation.toggleUserInputOption("sqlite", in: "storage")
|
|
conversation.submitUserInput()
|
|
#expect(
|
|
await answering.value
|
|
== .answered([
|
|
AgentUserInputAnswer(
|
|
questionID: "storage", value: .choice(selectedIDs: ["sqlite"], custom: nil)
|
|
)
|
|
])
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func aFreeFormQuestionStillAnswersAsPlainText() async {
|
|
let conversation = ConversationModel()
|
|
let pending = AgentUserInputRequest(
|
|
questions: [AgentUserQuestion(id: "base_url", prompt: "Base URL?", kind: .text)]
|
|
)
|
|
let answering = Task { await conversation.request(pending) }
|
|
await settle(conversation)
|
|
|
|
conversation.setUserInputCustomAnswer("https://api.example.com", for: "base_url")
|
|
conversation.submitUserInput()
|
|
|
|
#expect(
|
|
await answering.value
|
|
== .answered([
|
|
AgentUserInputAnswer(
|
|
questionID: "base_url", value: .text("https://api.example.com")
|
|
)
|
|
])
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func aSecretAnswersThroughItsOwnDraftAndNotTheSharedOne() async {
|
|
let conversation = ConversationModel()
|
|
let pending = AgentUserInputRequest(
|
|
questions: [AgentUserQuestion(id: "secret", prompt: "Sudo password?", kind: .secret)]
|
|
)
|
|
let answering = Task { await conversation.request(pending) }
|
|
await settle(conversation)
|
|
|
|
#expect(!conversation.canSubmitUserInput)
|
|
conversation.secretInputDraft = "hunter2"
|
|
#expect(conversation.canSubmitUserInput)
|
|
conversation.submitUserInput()
|
|
|
|
#expect(
|
|
await answering.value
|
|
== .answered([
|
|
AgentUserInputAnswer(questionID: "secret", value: .secret("hunter2"))
|
|
])
|
|
)
|
|
// Never left behind in the plain drafts, which are what the transcript
|
|
// and the next question read from.
|
|
#expect(conversation.userInputDraft(for: "secret").text.isEmpty)
|
|
#expect(conversation.secretInputDraft.isEmpty)
|
|
}
|
|
|
|
// MARK: - What the model reads
|
|
|
|
/// The skipped question still appears, carrying a null. Omitting it would
|
|
/// read as a bug; a null says plainly that this one is now the model's call.
|
|
@Test
|
|
func everyQuestionIsReportedWhetherAnsweredOrNot() {
|
|
let questions = threeQuestions().questions
|
|
let rows = AgentBuiltInTools.reported(
|
|
questions,
|
|
[
|
|
AgentUserInputAnswer(
|
|
questionID: "migrations",
|
|
value: .choice(selectedIDs: ["flyway", "liquibase"], custom: "or none")
|
|
),
|
|
AgentUserInputAnswer(questionID: "host", value: .text("staging-2")),
|
|
]
|
|
)
|
|
|
|
#expect(rows.count == 3)
|
|
// Order follows the questions, not the answers.
|
|
#expect(field(rows[0], "question_id") == .string("storage"))
|
|
#expect(field(rows[0], "answer") == .null)
|
|
#expect(field(rows[0], "selected_option_ids") == .array([]))
|
|
#expect(field(rows[0], "custom_text") == .null)
|
|
|
|
// A typed answer wins the canonical `answer`, and the ids still travel.
|
|
#expect(field(rows[1], "answer") == .string("or none"))
|
|
#expect(
|
|
field(rows[1], "selected_option_ids")
|
|
== .array([.string("flyway"), .string("liquibase")])
|
|
)
|
|
#expect(field(rows[2], "answer") == .string("staging-2"))
|
|
}
|
|
|
|
/// With no typed answer the canonical `answer` is the labels, not the ids —
|
|
/// a model that reads one field should read something a human wrote.
|
|
@Test
|
|
func selectedLabelsBecomeTheCanonicalAnswer() {
|
|
let rows = AgentBuiltInTools.reported(
|
|
[storage(multiple: true)],
|
|
[
|
|
AgentUserInputAnswer(
|
|
questionID: "storage",
|
|
value: .choice(selectedIDs: ["postgres", "duckdb"], custom: nil)
|
|
)
|
|
]
|
|
)
|
|
#expect(field(rows[0], "answer") == .string("Postgres, DuckDB"))
|
|
#expect(field(rows[0], "custom_text") == .null)
|
|
}
|
|
|
|
/// Unreachable through `request_user_input`, which never builds a secret
|
|
/// question — but if one ever arrives, the plaintext must not be echoed.
|
|
@Test
|
|
func aSecretIsNeverReportedBackToTheModel() {
|
|
let rows = AgentBuiltInTools.reported(
|
|
[AgentUserQuestion(id: "secret", prompt: "Password?", kind: .secret)],
|
|
[AgentUserInputAnswer(questionID: "secret", value: .secret("hunter2"))]
|
|
)
|
|
#expect(field(rows[0], "answer") == .null)
|
|
#expect(field(rows[0], "custom_text") == .null)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func field(_ row: AgentJSONValue, _ key: String) -> AgentJSONValue? {
|
|
row.objectValue?[key]
|
|
}
|
|
|
|
private func arguments(_ object: [String: AgentJSONValue]) throws -> Arguments {
|
|
try Arguments(
|
|
AgentToolInvocation(
|
|
runID: UUID(),
|
|
call: AgentToolCall(
|
|
id: "call-1", name: "request_user_input", arguments: .object(object)
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
private func request(multiple: Bool) -> AgentUserInputRequest {
|
|
AgentUserInputRequest(questions: [storage(multiple: multiple)])
|
|
}
|
|
|
|
private func threeQuestions() -> AgentUserInputRequest {
|
|
AgentUserInputRequest(questions: [
|
|
storage(multiple: false),
|
|
AgentUserQuestion(
|
|
id: "migrations", header: "Migrations", prompt: "Which tools may we use?",
|
|
kind: .choice(
|
|
AgentUserInputChoice(
|
|
options: [
|
|
AgentUserInputOption(id: "flyway", label: "Flyway"),
|
|
AgentUserInputOption(id: "liquibase", label: "Liquibase"),
|
|
],
|
|
allowsMultipleAnswers: true
|
|
)
|
|
)
|
|
),
|
|
AgentUserQuestion(id: "host", prompt: "Which host is staging?", kind: .text),
|
|
])
|
|
}
|
|
|
|
private func storage(multiple: Bool) -> AgentUserQuestion {
|
|
AgentUserQuestion(
|
|
id: "storage", header: "Storage", prompt: "Which database?",
|
|
kind: .choice(
|
|
AgentUserInputChoice(
|
|
options: [
|
|
AgentUserInputOption(
|
|
id: "postgres", label: "Postgres",
|
|
detail: "Matches the existing schema.", isRecommended: true
|
|
),
|
|
AgentUserInputOption(id: "sqlite", label: "SQLite"),
|
|
AgentUserInputOption(id: "duckdb", label: "DuckDB"),
|
|
],
|
|
allowsMultipleAnswers: multiple
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
/// `request(_:)` publishes the pending question and then suspends on its
|
|
/// continuation. Waiting for that to be installed is what keeps these tests
|
|
/// from racing the answer in ahead of the question.
|
|
private func settle(_ conversation: ConversationModel) async {
|
|
for _ in 0..<100 where conversation.pendingUserInput == nil {
|
|
await Task.yield()
|
|
}
|
|
#expect(conversation.pendingUserInput != nil, "the request never became pending")
|
|
}
|
|
}
|