fabro/lib/components/fabro-agent/src/question_tools.rs
Bryan Helmkamp 2e8d6b8a3d
Merge origin/main into the sandbox-driver adoption
Both sides rewrote the same crates. This branch replaced fabro's sandbox
layer with the sandbox driver: one RunSandbox, no Sandbox trait, driver
events consumed directly, MockSandbox over the driver's doubles. Main
replaced fabro's LLM layer with lithos-llm: fabro-model deleted, the
catalog and provider ids from lithos, credentials through the lithos
CredentialProvider, clients built with build_client.

Every conflict was one of those two renames meeting in an import list or
a signature, so the rule was mechanical: sandbox names resolve to this
branch, LLM names to main. Where main's newer code still used the old
sandbox API — new session tests over Arc::new(MockSandbox), the SDK
example's LocalSandbox, test fakes typed as Arc<dyn Sandbox> — it is
ported to RunSandbox and the mock helper. Where this branch still used
fabro-model or Client::from_source, main's replacement stands. One
combined future in the CLI runner crossed clippy's size budget and is
boxed at its call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:37:11 -06:00

976 lines
35 KiB
Rust

//! Model-native tools that let a root workflow agent ask the human for input.
use std::collections::BTreeMap;
use std::future::Future;
use std::ops::RangeInclusive;
use std::sync::Arc;
use async_trait::async_trait;
use fabro_types::{AgentProfileKind, InterviewOption, QuestionType};
use lithos_llm::types::ToolDefinition;
use serde::Deserialize;
use serde_json::json;
use tokio_util::sync::CancellationToken;
use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource};
tokio::task_local! {
static CURRENT_AGENT_TOOL_RUNTIME: AgentToolRuntime;
}
pub const OPENAI_REQUEST_USER_INPUT_TOOL: &str = "request_user_input";
pub const ANTHROPIC_ASK_USER_QUESTION_TOOL: &str = "AskUserQuestion";
pub const OPTION_DESCRIPTION_MAX_CHARS: usize = 2_000;
pub const OPTION_PREVIEW_MAX_CHARS: usize = 4_000;
const ROOT_SESSION_REQUIRED_ERROR: &str =
"human-question tools are available only during a root workflow agent session";
#[derive(Clone, Default)]
pub struct AgentToolRuntime {
question_runtime: Option<Arc<dyn AgentQuestionRuntime>>,
}
impl AgentToolRuntime {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_question_runtime(runtime: Arc<dyn AgentQuestionRuntime>) -> Self {
Self {
question_runtime: Some(runtime),
}
}
#[must_use]
pub fn question_runtime(&self) -> Option<Arc<dyn AgentQuestionRuntime>> {
self.question_runtime.clone()
}
}
pub async fn scope_agent_tool_runtime<F>(runtime: AgentToolRuntime, future: F) -> F::Output
where
F: Future,
{
CURRENT_AGENT_TOOL_RUNTIME.scope(runtime, future).await
}
fn current_agent_tool_runtime() -> AgentToolRuntime {
CURRENT_AGENT_TOOL_RUNTIME
.try_with(Clone::clone)
.unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentQuestion {
pub original_id: Option<String>,
pub original_question: String,
pub header: Option<String>,
pub text: String,
pub question_type: QuestionType,
pub options: Vec<InterviewOption>,
pub allow_freeform: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentQuestionAnswerStatus {
Answered,
Cancelled,
Interrupted,
Skipped,
Timeout,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentQuestionAnswer {
pub original_id: Option<String>,
pub original_question: String,
pub answers: Vec<String>,
pub status: AgentQuestionAnswerStatus,
}
#[async_trait]
pub trait AgentQuestionRuntime: Send + Sync {
async fn ask_questions(
&self,
tool_call_id: &str,
questions: Vec<AgentQuestion>,
cancel_token: CancellationToken,
) -> Result<Vec<AgentQuestionAnswer>, String>;
}
#[derive(Debug, Deserialize)]
struct OpenAiQuestionToolArgs {
questions: Vec<OpenAiQuestion>,
}
#[derive(Debug, Deserialize)]
struct OpenAiQuestion {
id: String,
header: String,
question: String,
#[serde(default)]
options: Vec<OpenAiOption>,
}
#[derive(Debug, Deserialize)]
struct OpenAiOption {
label: String,
#[serde(default)]
description: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AnthropicQuestionToolArgs {
questions: Vec<AnthropicQuestion>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AnthropicQuestion {
question: String,
#[serde(default)]
header: Option<String>,
#[serde(default)]
options: Vec<AnthropicOption>,
#[serde(default)]
multi_select: bool,
}
#[derive(Debug, Deserialize)]
struct AnthropicOption {
label: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
preview: Option<String>,
}
/// Contract rules the JSON Schema cannot express, and which differ between
/// the two harnesses sharing one normalizer.
struct QuestionLimits {
questions: RangeInclusive<usize>,
questions_error: &'static str,
/// `None` leaves the option count unbounded.
options: Option<RangeInclusive<usize>>,
options_error: &'static str,
max_header_chars: Option<usize>,
/// Claude 5's schema marks `header` and every option `description`
/// required, so both are validated rather than passed through as given.
require_header_and_descriptions: bool,
/// Claude 5 renders multi-select without a preview pane.
allow_preview_with_multi_select: bool,
}
const ANTHROPIC_QUESTION_LIMITS: QuestionLimits = QuestionLimits {
questions: 1..=usize::MAX,
questions_error: "questions must contain at least one question",
options: None,
options_error: "",
max_header_chars: None,
require_header_and_descriptions: false,
allow_preview_with_multi_select: true,
};
const CLAUDE5_QUESTION_LIMITS: QuestionLimits = QuestionLimits {
questions: 1..=4,
questions_error: "questions must contain between one and four questions",
options: Some(2..=4),
options_error: "each question must contain between two and four options",
max_header_chars: Some(12),
require_header_and_descriptions: true,
allow_preview_with_multi_select: false,
};
#[must_use]
pub fn is_question_tool(name: &str) -> bool {
matches!(
name,
OPENAI_REQUEST_USER_INPUT_TOOL | ANTHROPIC_ASK_USER_QUESTION_TOOL
)
}
pub fn register_question_tools(profile_kind: AgentProfileKind, registry: &mut ToolRegistry) {
match profile_kind {
// Codex names this tool `request_user_input` for GPT-5.6 and GPT-6 too.
AgentProfileKind::OpenAi | AgentProfileKind::Gpt56 | AgentProfileKind::Gpt6 => {
registry.register(make_openai_question_tool());
}
// Kimi Code names this tool `AskUserQuestion` with the same
// question/option shape, so the Anthropic-style tool is a match.
AgentProfileKind::Anthropic | AgentProfileKind::Kimi => {
registry.register(make_anthropic_question_tool());
}
AgentProfileKind::Claude5 => {
registry.register(make_claude5_question_tool());
}
AgentProfileKind::Gemini => {}
}
}
fn make_openai_question_tool() -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition::function(
OPENAI_REQUEST_USER_INPUT_TOOL.to_string(),
"Ask the human one or more questions and wait for their answers before continuing this stage.",
json!({
"type": "object",
"required": ["questions"],
"properties": {
"questions": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["id", "header", "question", "options"],
"properties": {
"id": { "type": "string" },
"header": { "type": "string" },
"question": { "type": "string" },
"options": {
"type": "array",
"items": {
"type": "object",
"required": ["label"],
"properties": {
"label": { "type": "string" },
"description": { "type": "string" }
}
}
}
}
}
}
}
}),
),
executor: Arc::new(|args, ctx| {
Box::pin(async move {
let parsed: OpenAiQuestionToolArgs = parse_tool_args(args)?;
let questions = normalize_openai_questions(parsed)?;
let answers = execute_question_tool(ctx, questions).await?;
format_openai_answers(&answers)
})
}),
source: ToolSource::Native,
}
}
fn make_anthropic_question_tool() -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition::function(
ANTHROPIC_ASK_USER_QUESTION_TOOL.to_string(),
"Ask the human one or more questions and wait for their answers before continuing this stage.",
json!({
"type": "object",
"required": ["questions"],
"properties": {
"questions": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["question", "options", "multiSelect"],
"properties": {
"question": { "type": "string" },
"header": { "type": "string" },
"options": {
"type": "array",
"items": {
"type": "object",
"required": ["label"],
"properties": {
"label": { "type": "string" },
"description": { "type": "string" },
"preview": { "type": "string" }
}
}
},
"multiSelect": { "type": "boolean" }
}
}
}
}
}),
),
executor: Arc::new(|args, ctx| {
Box::pin(async move {
let parsed: AnthropicQuestionToolArgs = parse_tool_args(args)?;
let questions = normalize_anthropic_questions(parsed, &ANTHROPIC_QUESTION_LIMITS)?;
let answers = execute_question_tool(ctx, questions).await?;
format_anthropic_answers(&answers)
})
}),
source: ToolSource::Native,
}
}
fn make_claude5_question_tool() -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition::function(
ANTHROPIC_ASK_USER_QUESTION_TOOL.to_string(),
"Ask the human up to four questions when a decision is genuinely theirs to make. The UI automatically provides an Other option for custom text.",
json!({
"type": "object",
"properties": {
"questions": {
"description": "Questions to ask the user (1-4 questions)",
"type": "array",
"minItems": 1,
"maxItems": 4,
"items": {
"type": "object",
"properties": {
"question": {
"description": "The complete, clear, and specific question to ask.",
"type": "string"
},
"header": {
"description": "Very short label displayed as a chip/tag (max 12 chars).",
"type": "string"
},
"options": {
"description": "Two to four choices. Do not include Other; the UI adds it automatically.",
"type": "array",
"minItems": 2,
"maxItems": 4,
"items": {
"type": "object",
"properties": {
"label": {
"description": "Concise display text for the option.",
"type": "string"
},
"description": {
"description": "What the option means and its relevant trade-offs.",
"type": "string"
},
"preview": {
"description": "Optional Markdown preview for single-select visual comparisons.",
"type": "string"
}
},
"required": ["label", "description"],
"additionalProperties": false
}
},
"multiSelect": {
"description": "Whether the user may select multiple options.",
"default": false,
"type": "boolean"
}
},
"required": ["question", "header", "options", "multiSelect"],
"additionalProperties": false
}
}
},
"required": ["questions"],
"additionalProperties": false
}),
),
executor: Arc::new(|args, ctx| {
Box::pin(async move {
let parsed: AnthropicQuestionToolArgs = parse_tool_args(args)?;
let questions = normalize_anthropic_questions(parsed, &CLAUDE5_QUESTION_LIMITS)?;
let answers = execute_question_tool(ctx, questions).await?;
format_anthropic_answers(&answers)
})
}),
source: ToolSource::Native,
}
}
fn parse_tool_args<T: for<'de> Deserialize<'de>>(args: serde_json::Value) -> Result<T, String> {
serde_json::from_value(args).map_err(|err| format!("invalid question tool arguments: {err}"))
}
async fn execute_question_tool(
ctx: ToolContext,
questions: Vec<AgentQuestion>,
) -> Result<Vec<AgentQuestionAnswer>, String> {
let session_id = ctx
.session_id
.as_deref()
.ok_or_else(|| ROOT_SESSION_REQUIRED_ERROR.to_string())?;
let root_session_id = ctx
.root_session_id
.as_deref()
.ok_or_else(|| ROOT_SESSION_REQUIRED_ERROR.to_string())?;
if session_id != root_session_id {
return Err(
"human-question tools are only available to the root agent; subagents must report back to their parent".to_string(),
);
}
let tool_call_id = ctx
.tool_call_id
.as_deref()
.ok_or_else(|| "human-question tool call is missing a provider tool_call_id".to_string())?;
let runtime = current_agent_tool_runtime().question_runtime().ok_or_else(|| {
"human-question tools are available only inside a workflow run with an active interviewer".to_string()
})?;
runtime
.ask_questions(tool_call_id, questions, ctx.cancel.clone())
.await
}
fn normalize_openai_questions(args: OpenAiQuestionToolArgs) -> Result<Vec<AgentQuestion>, String> {
if args.questions.is_empty() {
return Err("questions must contain at least one question".to_string());
}
args.questions
.into_iter()
.map(|question| {
let original_question = question.question.trim().to_string();
Ok(AgentQuestion {
original_id: Some(non_empty(&question.id, "question id")?),
text: display_text(Some(question.header.as_str()), &question.question),
header: Some(question.header),
original_question,
question_type: QuestionType::MultipleChoice,
options: options_from_openai(question.options),
allow_freeform: true,
})
})
.collect()
}
fn normalize_anthropic_questions(
args: AnthropicQuestionToolArgs,
limits: &QuestionLimits,
) -> Result<Vec<AgentQuestion>, String> {
if !limits.questions.contains(&args.questions.len()) {
return Err(limits.questions_error.to_string());
}
args.questions
.into_iter()
.map(|question| {
let original_question = non_empty(&question.question, "question")?;
let header = if limits.require_header_and_descriptions {
let header = non_empty(
question.header.as_deref().unwrap_or_default(),
"question header",
)?;
if limits
.max_header_chars
.is_some_and(|max| header.chars().count() > max)
{
return Err(format!(
"question header must contain at most {} characters",
limits.max_header_chars.unwrap_or_default()
));
}
Some(header)
} else {
question.header
};
if let Some(bounds) = &limits.options {
if !bounds.contains(&question.options.len()) {
return Err(limits.options_error.to_string());
}
}
if !limits.allow_preview_with_multi_select
&& question.multi_select
&& question
.options
.iter()
.any(|option| option.preview.is_some())
{
return Err(
"option previews are not supported for multi-select questions".to_string(),
);
}
// The lenient contract renders the question and header exactly as
// supplied; the strict one has already trimmed them.
let text = if limits.require_header_and_descriptions {
display_text(header.as_deref(), &original_question)
} else {
display_text(header.as_deref(), &question.question)
};
Ok(AgentQuestion {
original_id: None,
text,
header,
original_question,
question_type: if question.multi_select {
QuestionType::MultiSelect
} else {
QuestionType::MultipleChoice
},
options: options_from_anthropic(question.options, limits)?,
allow_freeform: true,
})
})
.collect()
}
fn options_from_openai(options: Vec<OpenAiOption>) -> Vec<InterviewOption> {
options
.into_iter()
.enumerate()
.map(|(idx, option)| InterviewOption {
key: option_key(idx),
label: option.label,
description: option
.description
.map(|value| bounded_display_field(&value, OPTION_DESCRIPTION_MAX_CHARS)),
preview: None,
})
.collect()
}
fn options_from_anthropic(
options: Vec<AnthropicOption>,
limits: &QuestionLimits,
) -> Result<Vec<InterviewOption>, String> {
options
.into_iter()
.enumerate()
.map(|(idx, option)| {
let (label, description) = if limits.require_header_and_descriptions {
(
non_empty(&option.label, "option label")?,
Some(non_empty(
option.description.as_deref().unwrap_or_default(),
"option description",
)?),
)
} else {
(option.label, option.description)
};
Ok(InterviewOption {
key: option_key(idx),
label,
description: description
.map(|value| bounded_display_field(&value, OPTION_DESCRIPTION_MAX_CHARS)),
preview: option
.preview
.map(|value| bounded_display_field(&value, OPTION_PREVIEW_MAX_CHARS)),
})
})
.collect()
}
fn option_key(idx: usize) -> String {
format!("option_{}", idx + 1)
}
fn non_empty(value: &str, field: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
Err(format!("{field} must not be empty"))
} else {
Ok(trimmed.to_string())
}
}
fn display_text(header: Option<&str>, question: &str) -> String {
let header = header.map(str::trim).filter(|value| !value.is_empty());
let question = question.trim();
match (header, question.is_empty()) {
(Some(header), false) => format!("{header}\n\n{question}"),
(Some(header), true) => header.to_string(),
(None, false) => question.to_string(),
(None, true) => String::new(),
}
}
fn bounded_display_field(value: &str, max_chars: usize) -> String {
match value.char_indices().nth(max_chars) {
Some((byte_idx, _)) => value[..byte_idx].to_string(),
None => value.to_string(),
}
}
fn ensure_all_answered(answers: &[AgentQuestionAnswer]) -> Result<(), String> {
if let Some(answer) = answers
.iter()
.find(|answer| answer.status != AgentQuestionAnswerStatus::Answered)
{
return Err(format!(
"human-question request ended before the user answered `{}`: {}",
answer.original_question,
answer_status_label(answer.status)
));
}
Ok(())
}
fn answer_status_label(status: AgentQuestionAnswerStatus) -> &'static str {
match status {
AgentQuestionAnswerStatus::Answered => "answered",
AgentQuestionAnswerStatus::Cancelled => "cancelled",
AgentQuestionAnswerStatus::Interrupted => "interrupted",
AgentQuestionAnswerStatus::Skipped => "skipped",
AgentQuestionAnswerStatus::Timeout => "timed out",
}
}
fn format_openai_answers(answers: &[AgentQuestionAnswer]) -> Result<String, String> {
ensure_all_answered(answers)?;
let mut answer_map = BTreeMap::new();
for answer in answers {
let Some(original_id) = answer.original_id.as_ref() else {
return Err(
"OpenAI question answer is missing the original model question id".to_string(),
);
};
answer_map.insert(original_id.clone(), json!({ "answers": answer.answers }));
}
serde_json::to_string(&json!({ "answers": answer_map }))
.map_err(|err| format!("failed to serialize answers: {err}"))
}
fn format_anthropic_answers(answers: &[AgentQuestionAnswer]) -> Result<String, String> {
ensure_all_answered(answers)?;
let pairs = answers
.iter()
.map(|answer| {
let question = json!(answer.original_question);
let answer_text = json!(answer.answers.join(", "));
format!("{question}={answer_text}")
})
.collect::<Vec<_>>()
.join(", ");
Ok(format!(
"User has answered your questions: {pairs}. You can now continue with the task."
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::native_tool::ToolVocabulary;
use crate::test_support::MockSandbox;
use crate::tool_registry::ToolDefinitionExt;
fn answered(
original_id: Option<&str>,
question: &str,
answers: &[&str],
) -> AgentQuestionAnswer {
AgentQuestionAnswer {
original_id: original_id.map(str::to_string),
original_question: question.to_string(),
answers: answers.iter().map(|value| (*value).to_string()).collect(),
status: AgentQuestionAnswerStatus::Answered,
}
}
#[test]
fn openai_request_with_descriptions_normalizes_to_multiple_choice() {
let args: OpenAiQuestionToolArgs = serde_json::from_value(json!({
"questions": [{
"id": "q1",
"header": "Decision",
"question": "Which path?",
"options": [{ "label": "Ship", "description": "Deploy now" }]
}]
}))
.unwrap();
let questions = normalize_openai_questions(args).unwrap();
assert_eq!(questions.len(), 1);
assert_eq!(questions[0].original_id.as_deref(), Some("q1"));
assert_eq!(questions[0].question_type, QuestionType::MultipleChoice);
assert!(questions[0].allow_freeform);
assert_eq!(questions[0].text, "Decision\n\nWhich path?");
assert_eq!(questions[0].options[0].key, "option_1");
assert_eq!(questions[0].options[0].label, "Ship");
assert_eq!(
questions[0].options[0].description.as_deref(),
Some("Deploy now")
);
}
#[test]
fn anthropic_multiselect_preserves_preview_and_formats_comma_joined_answers() {
let args: AnthropicQuestionToolArgs = serde_json::from_value(json!({
"questions": [{
"header": "Pick features",
"question": "Which features?",
"multiSelect": true,
"options": [{
"label": "Auth",
"description": "Login support",
"preview": "auth diff"
}]
}]
}))
.unwrap();
let questions = normalize_anthropic_questions(args, &ANTHROPIC_QUESTION_LIMITS).unwrap();
assert_eq!(questions[0].question_type, QuestionType::MultiSelect);
assert_eq!(
questions[0].options[0].preview.as_deref(),
Some("auth diff")
);
let text =
format_anthropic_answers(&[answered(None, "Which features?", &["Auth", "Billing"])])
.unwrap();
assert!(text.contains("\"Which features?\"=\"Auth, Billing\""));
}
#[test]
fn openai_answers_are_keyed_by_original_model_question_id() {
let text = format_openai_answers(&[
answered(Some("first"), "First?", &["Yes"]),
answered(Some("second"), "Second?", &["No"]),
])
.unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&text).unwrap(),
json!({
"answers": {
"first": { "answers": ["Yes"] },
"second": { "answers": ["No"] }
}
})
);
}
#[test]
fn option_description_and_preview_are_bounded() {
let long = "x".repeat(OPTION_PREVIEW_MAX_CHARS + 10);
assert_eq!(
bounded_display_field(&long, OPTION_DESCRIPTION_MAX_CHARS)
.chars()
.count(),
OPTION_DESCRIPTION_MAX_CHARS
);
assert_eq!(
bounded_display_field(&long, OPTION_PREVIEW_MAX_CHARS)
.chars()
.count(),
OPTION_PREVIEW_MAX_CHARS
);
}
#[test]
fn question_tool_registration_is_profile_specific() {
let mut openai = ToolRegistry::new();
register_question_tools(AgentProfileKind::OpenAi, &mut openai);
assert!(openai.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_some());
assert!(openai.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).is_none());
let mut gpt56 = ToolRegistry::with_vocabulary(ToolVocabulary::Codex);
register_question_tools(AgentProfileKind::Gpt56, &mut gpt56);
assert!(gpt56.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_some());
assert!(gpt56.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).is_none());
let mut anthropic = ToolRegistry::new();
register_question_tools(AgentProfileKind::Anthropic, &mut anthropic);
assert!(anthropic.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).is_some());
assert!(anthropic.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_none());
let mut kimi = ToolRegistry::new();
register_question_tools(AgentProfileKind::Kimi, &mut kimi);
assert!(kimi.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).is_some());
assert!(kimi.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_none());
let mut claude5 = ToolRegistry::with_vocabulary(ToolVocabulary::Claude5);
register_question_tools(AgentProfileKind::Claude5, &mut claude5);
let tool = claude5.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).unwrap();
assert_eq!(tool.definition.parameters()["additionalProperties"], false);
assert_eq!(
tool.definition.parameters()["properties"]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
vec!["questions"]
);
assert_eq!(
tool.definition.parameters()["properties"]["questions"]["maxItems"],
4
);
assert!(claude5.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_none());
let mut gemini = ToolRegistry::new();
register_question_tools(AgentProfileKind::Gemini, &mut gemini);
assert!(gemini.names().is_empty());
}
#[test]
fn claude5_question_contract_is_strict_and_preserves_preview() {
let args: AnthropicQuestionToolArgs = serde_json::from_value(json!({
"questions": [{
"header": "Approach",
"question": "Which approach should we use?",
"multiSelect": false,
"options": [
{
"label": "Simple",
"description": "Use the smallest implementation.",
"preview": "fn simple() {}"
},
{
"label": "Flexible",
"description": "Allow future extension."
}
]
}]
}))
.unwrap();
let questions = normalize_anthropic_questions(args, &CLAUDE5_QUESTION_LIMITS).unwrap();
assert_eq!(questions[0].header.as_deref(), Some("Approach"));
assert_eq!(
questions[0].options[0].preview.as_deref(),
Some("fn simple() {}")
);
assert!(questions[0].allow_freeform);
}
/// The Claude 5 payload is deserialized through the lenient struct now, so
/// the rules its own struct used to enforce are the normalizer's job.
#[test]
fn claude5_limits_reject_what_the_lenient_contract_allows() {
let question = |patch: serde_json::Value| {
let mut base = json!({
"question": "Which approach?",
"header": "Approach",
"multiSelect": false,
"options": [
{"label": "First", "description": "One"},
{"label": "Second", "description": "Two"}
]
});
let object = base.as_object_mut().unwrap();
for (key, value) in patch.as_object().unwrap() {
if value.is_null() {
object.remove(key);
} else {
object.insert(key.clone(), value.clone());
}
}
base
};
let normalize = |questions: serde_json::Value| {
let args: AnthropicQuestionToolArgs =
serde_json::from_value(json!({"questions": questions})).unwrap();
normalize_anthropic_questions(args, &CLAUDE5_QUESTION_LIMITS)
};
// A missing header and a missing option description used to be caught
// by serde; the normalizer has to reject them now.
assert!(normalize(json!([question(json!({"header": null}))])).is_err());
assert!(
normalize(json!([question(json!({
"options": [{"label": "First"}, {"label": "Second"}]
}))]))
.is_err()
);
assert!(
normalize(json!([question(json!({"header": "ThirteenChars"}))])).is_err(),
"header longer than 12 characters"
);
assert!(
normalize(json!([question(json!({
"options": [{"label": "Only", "description": "One"}]
}))]))
.is_err(),
"fewer than two options"
);
assert!(
normalize(json!(vec![question(json!({})); 5])).is_err(),
"more than four questions"
);
assert!(normalize(json!([question(json!({}))])).is_ok());
}
/// The same payloads stay acceptable under the lenient contract, so the
/// shared normalizer has not tightened the Anthropic tool.
#[test]
fn anthropic_limits_still_accept_optional_headers_and_descriptions() {
let args: AnthropicQuestionToolArgs = serde_json::from_value(json!({
"questions": [{
"question": "Which approach?",
"options": [{"label": "First"}]
}]
}))
.unwrap();
let questions = normalize_anthropic_questions(args, &ANTHROPIC_QUESTION_LIMITS).unwrap();
assert_eq!(questions.len(), 1);
assert_eq!(questions[0].header, None);
assert_eq!(questions[0].options[0].description, None);
}
#[test]
fn claude5_rejects_previews_for_multi_select_questions() {
let args: AnthropicQuestionToolArgs = serde_json::from_value(json!({
"questions": [{
"header": "Features",
"question": "Which features should we enable?",
"multiSelect": true,
"options": [
{
"label": "Auth",
"description": "Enable authentication.",
"preview": "auth = true"
},
{
"label": "Metrics",
"description": "Enable metrics."
}
]
}]
}))
.unwrap();
assert!(normalize_anthropic_questions(args, &CLAUDE5_QUESTION_LIMITS).is_err());
}
#[tokio::test]
async fn claude5_question_tool_rejects_subagent_sessions() {
let tool = make_claude5_question_tool();
let error = (tool.executor)(
json!({
"questions": [{
"header": "Approach",
"question": "Which approach?",
"multiSelect": false,
"options": [
{
"label": "Simple",
"description": "Use the simple approach."
},
{
"label": "Flexible",
"description": "Use the flexible approach."
}
]
}]
}),
ToolContext {
env: MockSandbox::default().sandbox(),
cancel: CancellationToken::new(),
tool_env_provider: None,
session_id: Some("child".to_string()),
root_session_id: Some("root".to_string()),
tool_call_id: Some("call".to_string()),
agent_event_emitter: None,
},
)
.await
.unwrap_err();
assert!(error.contains("only available to the root agent"));
}
}