Show every option a question offers, on the call that asked
Reported by Iris through the dev-updater session: a two-question AskUserQuestion arrived with only one option visible per question, so the answer she sent was the only one she had been offered. The cause was a `Row`. It hands out intrinsic widths in order and clips whatever runs past the edge, so the first option or two drew and the rest went off the side of the screen -- which does not read as a bug, it reads as those having been the only choices. The same Row was in the permission ask beside it; both wrap now. That pairing is the reason to look: a rule stated on one member of a set is usually missing from the others. The rest of what she asked for, and what each was: - It drew twice, as the tool call and again as loose question cards, because the backend marked these questions as belonging to no call. They belong to the call that asked, and now say so. - So it renders like any other tool: one card, its own heading, opened because a decision cannot be made from a closed row. - Each option shows its description and its `preview` block, which is the part a reader is deciding on and none of which was reaching them. - "Other" is a field on every question. The harness always offers it, so leaving it out narrowed a question that was never that narrow. - A multi-select sends the labels it collected as one string, which is the tool's own schema rather than a guess -- its answers map is string-valued. - No spinner while it waits. A spinner says the machine is working; here the machine is idle and the turn is stopped on the person, so the card says "your turn" in the colour this app already uses for that. Verified against a real session as well as the echo fixture: haiku asked two questions with three described options each, both were answered from the phone, and the model carried on with the answers. Echo grew `/ask` so the shape can be looked at without paying a model to produce one, and its option cards are outlined rather than tinted -- as one surface step up they were three paragraphs where three things to press should be.
This commit is contained in:
1 parent
cae04c2559
commit
fea8e7e92b
5 files changed
+518
-61
No files matched your search
@@ -0,0 +1,293 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* A question the model asked, as AskUserQuestion states it.
|
||||
*
|
||||
* Parsed from the tool call's own input rather than carried in the question event, because the
|
||||
* event's job is to be answerable -- an id and the labels that can be sent back -- and this is
|
||||
* everything the reader needs to *decide*, which is a longer list: what each option means, and
|
||||
* sometimes a worked example of what picking it would produce.
|
||||
*/
|
||||
data class AskedQuestion(
|
||||
val question: String,
|
||||
/** A short tag for the question, at most a dozen characters. Absent in older calls. */
|
||||
val header: String?,
|
||||
/** Whether more than one option may be chosen at once. */
|
||||
val multiSelect: Boolean,
|
||||
val options: List<AskedOption>,
|
||||
)
|
||||
|
||||
data class AskedOption(
|
||||
val label: String,
|
||||
val description: String?,
|
||||
/** A block to show as-is -- a mockup, a diff, a config file -- when the option carries one. */
|
||||
val preview: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reads the questions out of an AskUserQuestion call's input.
|
||||
*
|
||||
* Empty when the input is not that shape, which is what a caller falls back on: a question that
|
||||
* cannot be read in full is still answerable from the labels the event carried, and showing those
|
||||
* beats showing nothing.
|
||||
*/
|
||||
fun parseAskedQuestions(input: String): List<AskedQuestion> {
|
||||
val body =
|
||||
try {
|
||||
JSONObject(input)
|
||||
} catch (_: JSONException) {
|
||||
return emptyList()
|
||||
}
|
||||
val questions = body.optJSONArray("questions") ?: return emptyList()
|
||||
return (0 until questions.length()).mapNotNull { index ->
|
||||
val entry = questions.optJSONObject(index) ?: return@mapNotNull null
|
||||
val options = entry.optJSONArray("options")
|
||||
AskedQuestion(
|
||||
question =
|
||||
entry.optString("question").ifEmpty {
|
||||
return@mapNotNull null
|
||||
},
|
||||
header = entry.optString("header").ifEmpty { null },
|
||||
multiSelect = entry.optBoolean("multiSelect", false),
|
||||
options =
|
||||
(0 until (options?.length() ?: 0)).mapNotNull { at ->
|
||||
val option = options?.optJSONObject(at) ?: return@mapNotNull null
|
||||
AskedOption(
|
||||
label =
|
||||
option.optString("label").ifEmpty {
|
||||
return@mapNotNull null
|
||||
},
|
||||
description = option.optString("description").ifEmpty { null },
|
||||
preview = option.optString("preview").ifEmpty { null },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything an AskUserQuestion call is waiting for, on the call itself.
|
||||
*
|
||||
* The questions come from the call's input and the ids to answer with come from the events; they
|
||||
* are matched by the question's own text, which the backend uses verbatim as the prompt. An event
|
||||
* with no matching question still renders -- from its labels alone -- because a question that draws
|
||||
* as nothing is indistinguishable from one that was never asked, and the turn is blocked on it
|
||||
* either way.
|
||||
*/
|
||||
@Composable
|
||||
fun AskUserQuestionBody(
|
||||
input: String,
|
||||
asks: List<TranscriptItem.QuestionCard>,
|
||||
onAnswer: (questionId: String, answer: String) -> Unit,
|
||||
) {
|
||||
val asked = remember(input) { parseAskedQuestions(input) }
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
asks.forEach { ask ->
|
||||
Spacer(Modifier.height(12.dp))
|
||||
AskedQuestionCard(
|
||||
ask = ask,
|
||||
asked = asked.firstOrNull { it.question == ask.prompt },
|
||||
onAnswer = { answer -> onAnswer(ask.id, answer) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AskedQuestionCard(
|
||||
ask: TranscriptItem.QuestionCard,
|
||||
asked: AskedQuestion?,
|
||||
onAnswer: (String) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
asked?.header?.let { header ->
|
||||
// The tool's own chip text. Its own line rather than beside the question, because it
|
||||
// is a label *for* the question and the question is the thing to read.
|
||||
Text(
|
||||
header.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(ask.prompt, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (ask.answer != null) {
|
||||
Text(
|
||||
"Answered: ${ask.answer}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
val options = asked?.options ?: ask.options.map { AskedOption(it, null, null) }
|
||||
if (asked?.multiSelect == true) MultipleChoice(options, onAnswer)
|
||||
else
|
||||
options.forEach { option ->
|
||||
OptionCard(option, selected = false) { onAnswer(option.label) }
|
||||
}
|
||||
OtherAnswer(onAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options that can be chosen together, with one button to send them.
|
||||
*
|
||||
* The answer goes back as a string whatever the question, so several choices are one line: the
|
||||
* labels in the order they were offered. That is the tool's own schema -- its `answers` map is
|
||||
* string-valued -- rather than a guess about how a list would be encoded.
|
||||
*/
|
||||
@Composable
|
||||
private fun MultipleChoice(options: List<AskedOption>, onAnswer: (String) -> Unit) {
|
||||
var chosen by remember { mutableStateOf(setOf<String>()) }
|
||||
options.forEach { option ->
|
||||
OptionCard(option, selected = option.label in chosen) {
|
||||
chosen = if (option.label in chosen) chosen - option.label else chosen + option.label
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
onAnswer(options.filter { it.label in chosen }.joinToString(", ") { it.label })
|
||||
},
|
||||
enabled = chosen.isNotEmpty(),
|
||||
) {
|
||||
Text(if (chosen.size <= 1) "Send answer" else "Send ${chosen.size} answers")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One option: what it is called, what it means, and what it would produce.
|
||||
*
|
||||
* Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was
|
||||
* indistinguishable from the card behind it -- three paragraphs of text where three things to press
|
||||
* should have been, which is the failure a tint step routinely produces on a dark theme. A border
|
||||
* is one cue and it is unambiguous.
|
||||
*/
|
||||
@Composable
|
||||
private fun OptionCard(option: AskedOption, selected: Boolean, onPick: () -> Unit) {
|
||||
OutlinedCard(
|
||||
onClick = onPick,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
colors =
|
||||
CardDefaults.outlinedCardColors(
|
||||
containerColor =
|
||||
if (selected) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surface
|
||||
),
|
||||
// Picked shows in the border as well as the fill, because the fill alone is a colour
|
||||
// difference somebody has to have seen the unpicked version to notice.
|
||||
border =
|
||||
BorderStroke(
|
||||
if (selected) 2.dp else 1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(option.label, style = MaterialTheme.typography.titleSmall)
|
||||
option.description?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
option.preview?.let { Preview(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** An option's worked example, shown as written. */
|
||||
@Composable
|
||||
private fun Preview(preview: String) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
preview,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines
|
||||
// of the thing being previewed.
|
||||
softWrap = false,
|
||||
modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The choice the harness always adds, and the app has to as well.
|
||||
*
|
||||
* Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words
|
||||
* rather than pick. Leaving it out narrows a question that was never that narrow, and the reader
|
||||
* cannot tell that it was ever open.
|
||||
*/
|
||||
@Composable
|
||||
private fun OtherAnswer(onAnswer: (String) -> Unit) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text("Other") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = { onAnswer(text.trim()) }, enabled = text.isNotBlank()) {
|
||||
Text("Send")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The answers to a question, wrapped rather than in a row.
|
||||
*
|
||||
* A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question
|
||||
* with four options showed the first one or two and dropped the rest off the side of the screen.
|
||||
* That does not read as a bug: it reads as those having been the only choices, which is the worst
|
||||
* way for a list of choices to be wrong.
|
||||
*/
|
||||
@Composable
|
||||
fun AnswerOptions(options: List<String>, onAnswer: (String) -> Unit) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
options.forEach { option ->
|
||||
OutlinedButton(onClick = { onAnswer(option) }) { Text(option) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,14 +97,17 @@ sealed class TranscriptItem {
|
||||
val output: String,
|
||||
val done: Boolean,
|
||||
/**
|
||||
* The permission ask for this call, when there is one.
|
||||
* The questions this call is waiting on, in the order they were asked.
|
||||
*
|
||||
* On the call's own row rather than beside it: the ask used to arrive as a second card
|
||||
* On the call's own row rather than beside it: an ask used to arrive as a second card
|
||||
* repeating the input verbatim, so the reader saw the same command twice and had to work
|
||||
* out that it was one event. The backend says which call a permission is about, so this is
|
||||
* a fact rather than a match on the input.
|
||||
* out that it was one event. The backend says which call a question is about, so this is a
|
||||
* fact rather than a match on the input.
|
||||
*
|
||||
* A list because AskUserQuestion asks up to four at once, and they are one decision to make
|
||||
* -- a permission is the case of exactly one, not a different shape.
|
||||
*/
|
||||
val ask: QuestionCard? = null,
|
||||
val asks: List<QuestionCard> = emptyList(),
|
||||
/**
|
||||
* Images this call's result carried, drawn under it.
|
||||
*
|
||||
@@ -214,7 +217,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
event.about != null &&
|
||||
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
|
||||
) {
|
||||
updateTool(items, event.about) { it.copy(ask = card) }
|
||||
updateTool(items, event.about) { it.copy(asks = it.asks + card) }
|
||||
} else {
|
||||
items + card
|
||||
}
|
||||
@@ -227,8 +230,13 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
when {
|
||||
it is TranscriptItem.QuestionCard && it.id == event.id ->
|
||||
it.copy(answer = event.answer)
|
||||
it is TranscriptItem.ToolRun && it.ask?.id == event.id ->
|
||||
it.copy(ask = it.ask.copy(answer = event.answer))
|
||||
it is TranscriptItem.ToolRun && it.asks.any { ask -> ask.id == event.id } ->
|
||||
it.copy(
|
||||
asks =
|
||||
it.asks.map { ask ->
|
||||
if (ask.id == event.id) ask.copy(answer = event.answer) else ask
|
||||
}
|
||||
)
|
||||
else -> it
|
||||
}
|
||||
}
|
||||
@@ -798,10 +806,8 @@ fun SessionScreen(
|
||||
if (id in expandedTools) expandedTools - id
|
||||
else expandedTools + id
|
||||
},
|
||||
onAnswer = { call, answer ->
|
||||
call.ask?.let { ask ->
|
||||
act { answerQuestion(settings, summary.id, ask.id, answer) }
|
||||
}
|
||||
onAnswer = { questionId, answer ->
|
||||
act { answerQuestion(settings, summary.id, questionId, answer) }
|
||||
},
|
||||
image = { ref -> SessionImage(settings, summary.id, ref) },
|
||||
)
|
||||
@@ -819,16 +825,14 @@ fun SessionScreen(
|
||||
expandedTools - item.id
|
||||
else expandedTools + item.id
|
||||
},
|
||||
onAnswer = { answer ->
|
||||
item.ask?.let { ask ->
|
||||
act {
|
||||
answerQuestion(
|
||||
settings,
|
||||
summary.id,
|
||||
ask.id,
|
||||
answer,
|
||||
)
|
||||
}
|
||||
onAnswer = { questionId, answer ->
|
||||
act {
|
||||
answerQuestion(
|
||||
settings,
|
||||
summary.id,
|
||||
questionId,
|
||||
answer,
|
||||
)
|
||||
}
|
||||
},
|
||||
image = { ref -> SessionImage(settings, summary.id, ref) },
|
||||
@@ -1033,11 +1037,11 @@ private fun QuestionRow(question: TranscriptItem.QuestionCard, onAnswer: (String
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
question.options.forEach { option ->
|
||||
OutlinedButton(onClick = { onAnswer(option) }) { Text(option) }
|
||||
}
|
||||
}
|
||||
// Wrapped, not in a Row. A Row hands out intrinsic widths in order and clips
|
||||
// whatever runs past the edge, so a question with four options showed the first
|
||||
// one or two and silently dropped the rest off the side of the screen -- which
|
||||
// does not look like a bug, it looks like those were the only choices.
|
||||
AnswerOptions(question.options, onAnswer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -103,7 +102,7 @@ fun ToolGroup(
|
||||
onToggle: () -> Unit,
|
||||
isToolExpanded: (String) -> Boolean,
|
||||
onToolToggle: (String) -> Unit,
|
||||
onAnswer: (TranscriptItem.ToolRun, String) -> Unit,
|
||||
onAnswer: (questionId: String, answer: String) -> Unit,
|
||||
image: @Composable (String) -> Unit,
|
||||
) {
|
||||
if (!expanded) {
|
||||
@@ -127,7 +126,7 @@ fun ToolGroup(
|
||||
tool = call,
|
||||
expanded = isToolExpanded(call.id),
|
||||
onToggle = { onToolToggle(call.id) },
|
||||
onAnswer = { answer -> onAnswer(call, answer) },
|
||||
onAnswer = onAnswer,
|
||||
image = image,
|
||||
)
|
||||
}
|
||||
@@ -169,11 +168,11 @@ fun ToolCard(
|
||||
tool: TranscriptItem.ToolRun,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
onAnswer: (String) -> Unit,
|
||||
onAnswer: (questionId: String, answer: String) -> Unit,
|
||||
image: @Composable (String) -> Unit = {},
|
||||
) {
|
||||
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
|
||||
val deciding = tool.ask != null && tool.ask.answer == null
|
||||
val deciding = tool.asks.any { it.answer == null }
|
||||
val open = expanded || deciding
|
||||
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
@@ -200,7 +199,18 @@ fun ToolCard(
|
||||
)
|
||||
} ?: Spacer(Modifier.weight(1f))
|
||||
}
|
||||
if (!tool.done) {
|
||||
// A spinner says the machine is working. While this call is waiting on an
|
||||
// answer the machine is doing nothing at all -- the turn is stopped on the
|
||||
// person reading it -- so it says whose move it is instead, in the colour this
|
||||
// app uses everywhere for that.
|
||||
if (deciding) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"your turn",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = awaitingColor,
|
||||
)
|
||||
} else if (!tool.done) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(16.dp).height(16.dp),
|
||||
@@ -217,7 +227,12 @@ fun ToolCard(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
|
||||
// Everything AskUserQuestion carries is the questions, and those are drawn
|
||||
// below as something answerable; dumping the same JSON above them would be the
|
||||
// decision stated twice, once unreadably.
|
||||
if (tool.tool != ASK_USER_QUESTION) {
|
||||
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
|
||||
}
|
||||
if (tool.output.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Output", style = MaterialTheme.typography.labelSmall)
|
||||
@@ -229,7 +244,15 @@ fun ToolCard(
|
||||
// less than the one line it replaced -- unlike a command, which
|
||||
// is what the closed line already summarises.
|
||||
tool.images.forEach { ref -> image(ref) }
|
||||
tool.ask?.let { ask -> PermissionAsk(ask, onAnswer) }
|
||||
if (tool.asks.isNotEmpty()) {
|
||||
if (tool.tool == ASK_USER_QUESTION) {
|
||||
AskUserQuestionBody(tool.input, tool.asks, onAnswer)
|
||||
} else {
|
||||
tool.asks.forEach { ask ->
|
||||
PermissionAsk(ask) { answer -> onAnswer(ask.id, answer) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,10 +278,9 @@ private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (String) -
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ask.options.forEach { option ->
|
||||
OutlinedButton(onClick = { onAnswer(option) }) { Text(option) }
|
||||
}
|
||||
}
|
||||
AnswerOptions(ask.options, onAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool whose input is a question rather than a command; see [AskUserQuestionBody]. */
|
||||
private const val ASK_USER_QUESTION = "AskUserQuestion"
|
||||
Reference in new issue
Block a user