Let the user answer something the model did not offer

`request_user_input` took a prompt and an array of bare strings and drew a
`Picker`. When none of the choices fit — which is exactly when asking was
worth doing — Cancel was the only move available, and a cancelled run
tells the agent nothing about why. So a free-form field now sits in the
list alongside the options rather than under them as an afterthought, on
by default, and `allow_custom` has to be set false to take it away.

Options become objects. A bare string left the model nowhere to put the
tradeoff, so it either crammed the reasoning into the label or dropped
it, and the reader chose between four words with no way to tell them
apart. Each option now carries an id, a label, a sentence on what it
costs, and a `recommended` flag — a flag rather than a `(Recommended)`
suffix, so the convention lives in the schema instead of in prose the
model has to remember, and the label keeps its whole budget. The ceiling
drops from twenty choices to four; twenty was never a decision anyone was
being asked to make.

Nothing is preselected, the recommendation included. A recommendation is
a label, not a default, and a preselected answer is one the user can
submit without reading — so Continue stays disabled until they actually
answer. Where one answer is wanted the two routes are exclusive and each
clears the other, because a half-typed "something else" left sitting
under a chosen radio is a second answer contradicting the first. Where
several are wanted they are additive, since "these two, plus this other
thing" is a real reply.

Selected ids come back in the order the request listed them rather than
the order they were clicked: the model wrote the list and reads the
answer against it. Alongside them the tool returns `custom_text` and a
canonical `answer`, so a model that reads only one field still reads the
right one. Malformed and duplicated options are rejected rather than
dropped — a menu quietly missing the option the user wanted is a worse
failure than an error the model can see and retry.

`request_user_secret` shares the interaction handler, so it now refuses
`.choice` explicitly. A secret has to come back through `SecureField` and
the broker; anything else means it travelled the ordinary-answer path,
where it would have been echoed on screen and left in plain view state.

None of this is persisted, so there is nothing to migrate. Two things
were deliberately left out. Several questions per call does not fit a
tool that blocks mid-run on a single `pendingUserInput` — it would need a
queue and a multi-question card, and one question per call is right for
an agent that is already holding the user up. Per-option previews need a
two-pane layout, and this card is a narrow strip above the composer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kaede AkinoandClaude Opus 5 committed 2026-08-26 08:37:09 +08:00
1 parent cdea8d5448
commit 2ec0e123a3
5 files changed
+562 -22

No files matched your search

@@ -689,22 +689,75 @@ nonisolated enum AgentBuiltInTools {
private static func requestUserInput() -> AnyAgentTool {
AnyAgentTool(descriptor: descriptor(
"request_user_input", "Pause and ask the user for text or one structured choice.",
"request_user_input",
"""
Ask the user to decide something you cannot decide yourself, and wait for the answer. \
Use it only when the answer is genuinely theirs: a preference, a tradeoff with no \
defensible default, or a fact only they hold — which host is production, where a \
credential lives. If the repository, the request, or an obvious convention already \
answers it, choose, say which assumption you made, and keep going; a question the \
user has to stop and think about costs more than a decision you make and flag. Never \
use it to ask whether to proceed. Give 2-4 options, each with a description saying \
what it costs rather than restating the label, and put your recommendation on the \
option itself instead of in its wording. A free-form field is offered alongside them \
unless you set allow_custom false, so never write an "Other" option yourself. Omit \
options entirely for a question no menu can answer, such as a URL or a name.
""",
properties: [
"prompt": string(max: 4_096),
"choices": array(items: string(max: 256), max: 20),
"header": string(max: 24),
"options": array(
items: object(
properties: [
"id": string(max: 64),
"label": string(max: 120),
"description": string(max: 400),
"recommended": boolean(),
],
required: ["id", "label"]
),
max: 4
),
"multi_select": boolean(),
"allow_custom": boolean(),
"custom_placeholder": string(max: 120),
], required: ["prompt"], target: .user, safety: .locallyReadOnly
)) { invocation, context in
let arguments = try Arguments(invocation)
let choices = arguments.optionalStrings("choices")
let options = try arguments.userInputOptions()
let request = AgentUserInputRequest(
prompt: try arguments.string("prompt"),
kind: choices.isEmpty ? .text : .choice(choices)
kind: options.isEmpty
? .text
: .choice(
AgentUserInputChoice(
options: options,
allowsCustomAnswer: arguments.optionalBool("allow_custom") ?? true,
allowsMultipleAnswers: arguments.optionalBool("multi_select") ?? false,
customPlaceholder: arguments.optionalString("custom_placeholder") ?? ""
)
),
header: arguments.optionalString("header") ?? ""
)
switch await context.userInteraction.request(request) {
case .text(let answer): return result(invocation, .object(["answer": .string(answer)]))
case .secret: throw AgentToolError.invalidArguments("Secret input must use request_user_secret.")
case .cancelled: throw AgentToolError.cancelled
case .text(let answer):
return result(invocation, .object(["answer": .string(answer)]))
case .choice(let selectedIDs, let custom):
let labels = selectedIDs.compactMap { id in
options.first { $0.id == id }?.label
}
return result(invocation, .object([
// The canonical human answer, whichever route produced it,
// so a model that reads only one field still reads the
// right one.
"answer": .string(custom ?? labels.joined(separator: ", ")),
"selected_option_ids": .array(selectedIDs.map(AgentJSONValue.string)),
"custom_text": custom.map(AgentJSONValue.string) ?? .null,
]))
case .secret:
throw AgentToolError.invalidArguments("Secret input must use request_user_secret.")
case .cancelled:
throw AgentToolError.cancelled
}
}
}
@@ -737,7 +790,11 @@ nonisolated enum AgentBuiltInTools {
return result(invocation, .object([
"secret_handle": .string(handle.id.uuidString), "single_use": .bool(true),
]))
case .text: throw AgentToolError.invalidArguments("The secret prompt returned ordinary text.")
case .text, .choice:
// A secret must come back through `SecureField` and the broker.
// Anything else means it took the ordinary-answer path, where it
// would have been echoed on screen and kept in plain state.
throw AgentToolError.invalidArguments("The secret prompt returned ordinary text.")
case .cancelled: throw AgentToolError.cancelled
}
}
@@ -932,6 +989,35 @@ nonisolated struct Arguments: Sendable {
object[key]?.arrayValue?.compactMap(\.stringValue) ?? []
}
/// The `options` array of `request_user_input`.
///
/// Malformed rows are rejected rather than dropped. A menu silently missing
/// the option the user wanted is a worse failure than an error the model
/// can see and retry.
func userInputOptions() throws -> [AgentUserInputOption] {
guard let value = object["options"], value != .null else { return [] }
guard let rows = value.arrayValue else {
throw AgentToolError.invalidArguments("options must be an array.")
}
var seen: Set<String> = []
return try rows.map { row in
guard let fields = row.objectValue,
let id = fields["id"]?.stringValue, !id.isEmpty,
let label = fields["label"]?.stringValue, !label.isEmpty
else {
throw AgentToolError.invalidArguments("Each option requires id and label.")
}
guard seen.insert(id).inserted else {
throw AgentToolError.invalidArguments("Option ids must be unique: \(id).")
}
return AgentUserInputOption(
id: id, label: label,
detail: fields["description"]?.stringValue ?? "",
isRecommended: fields["recommended"]?.boolValue ?? false
)
}
}
func requestHeaders() throws -> [RestrictedHTTPHeader] {
guard let value = object["headers"], value != .null else { return [] }
guard let rows = value.arrayValue else {
+41 -1
View File
@@ -173,17 +173,57 @@ actor SecretBroker {
var count: Int { entries.count }
}
/// One choice offered by `request_user_input`.
///
/// A bare string used to be the whole option, which left the model nowhere to
/// put the tradeoff: it either crammed it into the label or lost it, and the
/// reader was left choosing between four words with no way to tell them apart.
nonisolated struct AgentUserInputOption: Identifiable, Hashable, Sendable {
var id: String
var label: String
/// One sentence on what this choice means or costs. Optional, and the
/// reason this type exists.
var detail: String = ""
/// Advisory only. It marks the model's recommendation; it never preselects,
/// because a preselected answer is one the user can submit without reading.
var isRecommended: Bool = false
}
nonisolated struct AgentUserInputChoice: Hashable, Sendable {
var options: [AgentUserInputOption]
/// Whether the user may answer with something the model did not think of.
///
/// Defaults to true, and that default is the whole point: an agent that
/// offers four wrong answers and no fifth field leaves Cancel as the only
/// honest response, and a cancelled run tells it nothing about why.
var allowsCustomAnswer: Bool = true
var allowsMultipleAnswers: Bool = false
var customPlaceholder: String = ""
}
nonisolated struct AgentUserInputRequest: Identifiable, Hashable, Sendable {
nonisolated enum Kind: Hashable, Sendable { case text, choice([String]), secret }
nonisolated enum Kind: Hashable, Sendable {
case text
case choice(AgentUserInputChoice)
case secret
}
var id: UUID = UUID()
var prompt: String
var kind: Kind
/// Short label naming the decision, shown above the prompt.
var header: String = ""
var purpose: String = ""
var hostID: UUID?
}
nonisolated enum AgentUserInputResponse: Hashable, Sendable {
case text(String)
/// Selected option ids in the order the request listed them, plus whatever
/// the user typed instead of — or, when several answers are allowed,
/// alongside — them. Kept structured rather than flattened to a string so
/// the tool can hand the model ids it chose itself.
case choice(selectedIDs: [String], custom: String?)
case secret(String)
case cancelled
}
+67 -7
View File
@@ -197,7 +197,10 @@ final class ConversationModel: Identifiable {
var toolCards: [AgentToolCard] = []
var pendingApproval: AgentApprovalRequest?
var pendingUserInput: AgentUserInputRequest?
/// The free-form answer, for a text question and for the "something else"
/// field beside a set of options.
var userInputDraft = ""
var userInputSelection: Set<String> = []
var secretInputDraft = ""
/// The model-facing history, which is not what `messages` holds.
///
@@ -433,31 +436,88 @@ final class ConversationModel: Identifiable {
func request(_ request: AgentUserInputRequest) async -> AgentUserInputResponse {
pendingUserInput = request
userInputDraft = ""
userInputSelection = []
secretInputDraft = ""
runState = .waitingForUser
return await withCheckedContinuation { inputWaiters[request.id] = $0 }
}
/// Nothing is preselected, so this is what stops Continue from returning an
/// answer the user never gave.
var canSubmitUserInput: Bool {
guard let request = pendingUserInput else { return false }
switch request.kind {
case .secret: return !secretInputDraft.isEmpty
case .text: return !trimmedUserInputDraft.isEmpty
case .choice: return !userInputSelection.isEmpty || !trimmedUserInputDraft.isEmpty
}
}
func toggleUserInputOption(_ id: String) {
guard case .choice(let choice)? = pendingUserInput?.kind else { return }
if choice.allowsMultipleAnswers {
if userInputSelection.contains(id) {
userInputSelection.remove(id)
} else {
userInputSelection.insert(id)
}
} else {
userInputSelection = userInputSelection.contains(id) ? [] : [id]
// One answer means one answer: a half-typed "something else" left
// sitting under a chosen radio would be a second, contradicting one.
userInputDraft = ""
}
}
/// The other half of that rule — typing clears the radio it would otherwise
/// silently disagree with. Left alone in a multi-answer question, where the
/// two are additive rather than exclusive.
func setUserInputCustomAnswer(_ text: String) {
userInputDraft = text
guard case .choice(let choice)? = pendingUserInput?.kind,
!choice.allowsMultipleAnswers, !text.isEmpty
else { return }
userInputSelection.removeAll()
}
func submitUserInput() {
guard let request = pendingUserInput else { return }
guard let request = pendingUserInput, canSubmitUserInput else { return }
let response: AgentUserInputResponse
switch request.kind {
case .secret: response = .secret(secretInputDraft)
case .text, .choice: response = .text(userInputDraft)
case .secret:
response = .secret(secretInputDraft)
case .text:
response = .text(userInputDraft)
case .choice(let choice):
let custom = trimmedUserInputDraft
response = .choice(
// Ordered by the request, not by whatever order the user
// clicked in — the model wrote the list and reads the answer
// against it.
selectedIDs: choice.options.map(\.id).filter(userInputSelection.contains),
custom: custom.isEmpty ? nil : custom
)
}
secretInputDraft = ""
userInputDraft = ""
pendingUserInput = nil
clearUserInput()
runState = .running
inputWaiters.removeValue(forKey: request.id)?.resume(returning: response)
}
func cancelUserInput() {
guard let request = pendingUserInput else { return }
clearUserInput()
inputWaiters.removeValue(forKey: request.id)?.resume(returning: .cancelled)
}
private var trimmedUserInputDraft: String {
userInputDraft.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func clearUserInput() {
secretInputDraft = ""
userInputDraft = ""
userInputSelection = []
pendingUserInput = nil
inputWaiters.removeValue(forKey: request.id)?.resume(returning: .cancelled)
}
func handle(_ event: AgentEvent) {
@@ -237,6 +237,12 @@ private struct AgentApprovalCard: View {
}
}
/// The agent asking the user to decide something.
///
/// Options are a list rather than a `Picker` because each one carries a
/// sentence explaining what it costs, and a pop-up menu has nowhere to put
/// that. Nothing is preselected — including the recommendation, which is a
/// label and not a default, so that an answer always takes a deliberate click.
private struct AgentUserInputCard: View {
let request: AgentUserInputRequest
let conversation: ConversationModel
@@ -244,27 +250,119 @@ private struct AgentUserInputCard: View {
var body: some View {
@Bindable var conversation = conversation
VStack(alignment: .leading, spacing: Metrics.Space.s) {
Text(request.prompt).font(.callout.weight(.medium))
if !request.header.isEmpty {
Text(request.header)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
Text(request.prompt)
.font(.callout.weight(.medium))
.fixedSize(horizontal: false, vertical: true)
switch request.kind {
case .secret:
SecureField("Secret", text: $conversation.secretInputDraft)
.textFieldStyle(.roundedBorder)
case .choice(let choices):
Picker("Answer", selection: $conversation.userInputDraft) {
Text("Choose…").tag("")
ForEach(choices, id: \.self) { Text($0).tag($0) }
}
case .choice(let choice):
choiceList(choice)
case .text:
TextField("Answer", text: $conversation.userInputDraft)
.textFieldStyle(.roundedBorder)
}
HStack {
Button("Cancel") { conversation.cancelUserInput() }
Spacer()
Button("Continue") { conversation.submitUserInput() }
.buttonStyle(.borderedProminent)
.disabled(!conversation.canSubmitUserInput)
}
}
.padding(Metrics.Space.l)
}
private func choiceList(_ choice: AgentUserInputChoice) -> some View {
VStack(alignment: .leading, spacing: Metrics.Space.xs) {
ForEach(choice.options) { option in
optionRow(option, choice: choice)
}
if choice.allowsCustomAnswer {
customRow(choice)
}
}
}
private func optionRow(_ option: AgentUserInputOption, choice: AgentUserInputChoice)
-> some View
{
let isSelected = conversation.userInputSelection.contains(option.id)
return Button {
conversation.toggleUserInputOption(option.id)
} label: {
HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.s) {
Image(systemName: marker(isSelected: isSelected, choice: choice))
.foregroundStyle(isSelected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
.frame(width: Metrics.Sidebar.rowIcon)
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
HStack(spacing: Metrics.Space.s) {
Text(option.label).font(.callout)
if option.isRecommended {
Text("Recommended")
.font(.caption2.weight(.medium))
.padding(.horizontal, Metrics.Space.s)
.padding(.vertical, 1)
.background(.tint.opacity(0.15), in: .capsule)
.foregroundStyle(.tint)
}
}
if !option.detail.isEmpty {
Text(option.detail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer(minLength: 0)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
}
/// The escape hatch, sitting in the list as one more choice rather than
/// below it as an afterthought — an answer the model did not think of is
/// not a lesser kind of answer.
private func customRow(_ choice: AgentUserInputChoice) -> some View {
HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.s) {
Image(
systemName: marker(
isSelected: !conversation.userInputDraft.isEmpty, choice: choice
)
)
.foregroundStyle(
conversation.userInputDraft.isEmpty
? AnyShapeStyle(.secondary) : AnyShapeStyle(.tint)
)
.frame(width: Metrics.Sidebar.rowIcon)
TextField(
choice.customPlaceholder.isEmpty
? String(localized: "Something else…") : choice.customPlaceholder,
text: Binding(
get: { conversation.userInputDraft },
set: { conversation.setUserInputCustomAnswer($0) }
)
)
.textFieldStyle(.roundedBorder)
}
}
private func marker(isSelected: Bool, choice: AgentUserInputChoice) -> String {
if choice.allowsMultipleAnswers {
isSelected ? "checkmark.square.fill" : "square"
} else {
isSelected ? "largecircle.fill.circle" : "circle"
}
}
}
+256
View File
@@ -0,0 +1,256 @@
import Foundation
import Testing
@testable import Termind
@MainActor
@Suite
struct AgentUserInputTests {
// MARK: - Parsing the tool's options
@Test
func optionsCarryTheirDescriptionAndRecommendation() throws {
let options = try arguments([
"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")]),
]),
]).userInputOptions()
#expect(options.count == 2)
#expect(options[0].id == "postgres")
#expect(options[0].detail == "JSONB, and it matches the existing schema.")
#expect(options[0].isRecommended)
// Absent is absent, not false-y noise.
#expect(options[1].detail.isEmpty)
#expect(!options[1].isRecommended)
}
@Test
func noOptionsMeansAFreeFormQuestion() throws {
#expect(try arguments(["prompt": .string("Which URL?")]).userInputOptions().isEmpty)
#expect(
try arguments(["prompt": .string("Which URL?"), "options": .null])
.userInputOptions().isEmpty
)
}
/// A malformed row is rejected rather than dropped: a menu quietly missing
/// the option the user wanted is worse than an error the model can retry.
@Test
func malformedOptionsAreRejectedRatherThanDropped() {
#expect(throws: AgentToolError.self) {
try arguments([
"prompt": .string("?"),
"options": .array([.object(["label": .string("No id here")])]),
]).userInputOptions()
}
#expect(throws: AgentToolError.self) {
try arguments([
"prompt": .string("?"),
"options": .array([.string("a bare string")]),
]).userInputOptions()
}
}
@Test
func duplicateOptionIdsAreRejected() {
#expect(throws: AgentToolError.self) {
try arguments([
"prompt": .string("?"),
"options": .array([
.object(["id": .string("a"), "label": .string("First")]),
.object(["id": .string("a"), "label": .string("Second")]),
]),
]).userInputOptions()
}
}
// MARK: - One answer, or several
@Test
func nothingIsPreselectedEvenWhenAnOptionIsRecommended() {
let conversation = ConversationModel()
conversation.pendingUserInput = request(multiple: false)
#expect(conversation.userInputSelection.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.pendingUserInput = request(multiple: false)
conversation.setUserInputCustomAnswer("MySQL")
#expect(conversation.canSubmitUserInput)
conversation.toggleUserInputOption("postgres")
#expect(conversation.userInputSelection == ["postgres"])
#expect(conversation.userInputDraft.isEmpty)
}
@Test
func typingInASingleAnswerQuestionClearsTheChosenOption() {
let conversation = ConversationModel()
conversation.pendingUserInput = request(multiple: false)
conversation.toggleUserInputOption("postgres")
conversation.setUserInputCustomAnswer("MySQL")
#expect(conversation.userInputSelection.isEmpty)
#expect(conversation.userInputDraft == "MySQL")
}
@Test
func singleAnswerSelectionReplacesRatherThanAccumulates() {
let conversation = ConversationModel()
conversation.pendingUserInput = request(multiple: false)
conversation.toggleUserInputOption("postgres")
conversation.toggleUserInputOption("sqlite")
#expect(conversation.userInputSelection == ["sqlite"])
// Clicking the chosen one again takes the answer back.
conversation.toggleUserInputOption("sqlite")
#expect(conversation.userInputSelection.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.pendingUserInput = request(multiple: true)
conversation.toggleUserInputOption("postgres")
conversation.toggleUserInputOption("sqlite")
conversation.setUserInputCustomAnswer("and DuckDB")
#expect(conversation.userInputSelection == ["postgres", "sqlite"])
#expect(conversation.userInputDraft == "and DuckDB")
}
// MARK: - What the tool gets back
@Test
func answerReportsIdsInTheOrderTheRequestListedThem() async {
let conversation = ConversationModel()
let pending = request(multiple: true)
let answering = Task { await conversation.request(pending) }
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")
conversation.toggleUserInputOption("postgres")
conversation.submitUserInput()
#expect(await answering.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 ")
conversation.submitUserInput()
// Trimmed, because trailing whitespace is not part of anyone's answer.
#expect(await answering.value == .choice(selectedIDs: [], custom: "MySQL"))
}
@Test
func cancellingReportsCancelledRatherThanAnEmptyAnswer() async {
let conversation = ConversationModel()
let answering = Task { await conversation.request(request(multiple: false)) }
await settle(conversation)
conversation.cancelUserInput()
#expect(await answering.value == .cancelled)
#expect(conversation.pendingUserInput == nil)
#expect(conversation.userInputSelection.isEmpty)
}
@Test
func submittingNothingIsRefusedRatherThanSentAsAnEmptyChoice() 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")
conversation.submitUserInput()
#expect(await answering.value == .choice(selectedIDs: ["sqlite"], custom: nil))
}
@Test
func aFreeFormQuestionStillAnswersAsPlainText() async {
let conversation = ConversationModel()
let answering = Task {
await conversation.request(AgentUserInputRequest(prompt: "Base URL?", kind: .text))
}
await settle(conversation)
conversation.userInputDraft = "https://api.example.com"
conversation.submitUserInput()
#expect(await answering.value == .text("https://api.example.com"))
}
// MARK: - Helpers
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(
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
)
),
header: "Storage"
)
}
/// `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")
}
}