agent: Add ask_user tool using elicitation forms (#61497)
## Summary Adds a first-party `ask_user` tool to Zed's native agent (`crates/agent`) that lets the agent ask the user a question and get an answer back through a proper **elicitation form** rather than free-form chat text. This is a step towards the interactive-question experience requested in [discussion #48784](https://github.com/zed-industries/zed/discussions/48784) (implement the ACP elicitation spec for agent questions). Zed already renders elicitation forms client-side; this wires the native agent up to that existing rail so the agent can drive single-select and/or free-text prompts. ## What it does The tool takes: - `question` — the prompt shown to the user - `options` — optional list of selectable choices (single-select) - `allow_free_text` — whether the user may type a custom answer instead of picking an option The agent decides the shape of each question: | `options` | `allow_free_text` | Result | | --- | --- | --- | | 2+ | `false` | Single-select only | | 2+ | `true` | Single-select with a free-text "other" field | | empty | `true` | Free-text only | A typed answer takes precedence over a selected option, and the tool always resolves (a cancelled or failed elicitation is reported back to the agent rather than hanging). ## Demo https://github.com/user-attachments/assets/9d438d87-af65-4fa2-b500-b1232007d0a0 ## Implementation - `thread.rs` — new `ThreadEvent::Elicitation(ElicitationRequest)` variant plus `ToolCallEventStream::request_elicitation`, mirroring the existing `prompt_for_decision` permission rail. - `agent.rs` — handles the elicitation event by building a session-scoped `acp::CreateElicitationRequest` and routing it through `AcpThread::request_elicitation`, piping the response back to the tool. - `tools/ask_user_tool.rs` — the tool itself, including schema construction and response extraction. - Registered in the `write` and `ask` profiles and excluded from the tool-permissions setup UI. No client-side UI changes were needed — the existing elicitation form rendering handles it. ## Tests `cargo test -p agent ask_user` — 5 unit tests covering schema construction, validation, and accept/decline/cancel handling. Release Notes: - Added an `ask_user` tool that lets the agent ask the user a question with selectable options and/or free-text input, rendered as an elicitation form
This commit is contained in:
1 parent
8fd8efd105
commit
d2bbf4a835
6 files changed
+505
-7
No files matched your search
@@ -1191,6 +1191,7 @@
|
||||
"delete_path": true,
|
||||
"diagnostics": true,
|
||||
"apply_code_action": true,
|
||||
"ask_user": true,
|
||||
"edit_file": true,
|
||||
"write_file": true,
|
||||
"fetch": true,
|
||||
@@ -1217,6 +1218,7 @@
|
||||
"tools": {
|
||||
"create_thread": true,
|
||||
"diagnostics": true,
|
||||
"ask_user": true,
|
||||
"fetch": true,
|
||||
"list_agents_and_models": true,
|
||||
"list_directory": true,
|
||||
|
||||
@@ -2235,6 +2235,49 @@ impl NativeAgentConnection {
|
||||
thread.authorize_tool_call(tool_call_id, outcome, cx);
|
||||
})?;
|
||||
}
|
||||
ThreadEvent::Elicitation(ElicitationRequest {
|
||||
tool_call_id,
|
||||
message,
|
||||
schema,
|
||||
response,
|
||||
}) => {
|
||||
let request_result = acp_thread.update(cx, |thread, cx| {
|
||||
let scope = acp::ElicitationSessionScope::new(
|
||||
thread.session_id().clone(),
|
||||
)
|
||||
.tool_call_id(tool_call_id);
|
||||
let request = acp::CreateElicitationRequest::new(
|
||||
acp::ElicitationFormMode::new(scope, schema),
|
||||
message,
|
||||
);
|
||||
thread.request_elicitation(request, cx)
|
||||
})?;
|
||||
match request_result {
|
||||
Ok(response_task) => {
|
||||
cx.background_spawn(async move {
|
||||
let elicitation_response = response_task.await;
|
||||
response
|
||||
.send(elicitation_response)
|
||||
.map_err(|_| {
|
||||
anyhow!("elicitation receiver was dropped")
|
||||
})
|
||||
.log_err();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("Failed to request elicitation: {error:?}");
|
||||
// Resolve the tool's pending request so it
|
||||
// doesn't hang waiting on a form that will
|
||||
// never render.
|
||||
response
|
||||
.send(acp::CreateElicitationResponse::new(
|
||||
acp::ElicitationAction::Cancel,
|
||||
))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
ThreadEvent::ToolCall(tool_call) => {
|
||||
acp_thread.update(cx, |thread, cx| {
|
||||
thread.upsert_tool_call(tool_call, cx)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::{
|
||||
ApplyCodeActionTool, CodeActionStore, ContextServerRegistry, CopyPathTool, CreateDirectoryTool,
|
||||
CreateThreadTool, DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool,
|
||||
FetchTool, FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool,
|
||||
ListAgentsAndModelsTool, ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool,
|
||||
RenameTool, SandboxedTerminalTool, SpawnAgentTool, SystemPromptTemplate, Template, Templates,
|
||||
TerminalTool, ToolPermissionDecision, WebSearchTool, WriteFileTool,
|
||||
decide_permission_from_settings,
|
||||
ApplyCodeActionTool, AskUserTool, CodeActionStore, ContextServerRegistry, CopyPathTool,
|
||||
CreateDirectoryTool, CreateThreadTool, DbLanguageModel, DbThread, DeletePathTool,
|
||||
DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, FindReferencesTool, GetCodeActionsTool,
|
||||
GoToDefinitionTool, GrepTool, ListAgentsAndModelsTool, ListDirectoryTool, MovePathTool,
|
||||
ProjectSnapshot, ReadFileTool, RenameTool, SandboxedTerminalTool, SpawnAgentTool,
|
||||
SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, WebSearchTool,
|
||||
WriteFileTool, decide_permission_from_settings,
|
||||
};
|
||||
use acp_thread::{ClientUserMessageId, MentionUri};
|
||||
use action_log::ActionLog;
|
||||
@@ -880,6 +880,7 @@ pub enum ThreadEvent {
|
||||
tool_call_id: acp::ToolCallId,
|
||||
outcome: acp_thread::SelectedPermissionOutcome,
|
||||
},
|
||||
Elicitation(ElicitationRequest),
|
||||
SubagentSpawned(acp::SessionId),
|
||||
Retry(acp_thread::RetryStatus),
|
||||
ContextCompaction(acp_thread::ContextCompaction),
|
||||
@@ -1166,6 +1167,19 @@ fn ensure_tool_call_authorization_not_interrupted(
|
||||
}
|
||||
}
|
||||
|
||||
/// A request from a tool to elicit structured input from the user via a form.
|
||||
///
|
||||
/// The scope (session id) is filled in by the connection layer when the request
|
||||
/// is handed to the [`AcpThread`], so tools only supply the form schema and the
|
||||
/// message to display.
|
||||
#[derive(Debug)]
|
||||
pub struct ElicitationRequest {
|
||||
pub tool_call_id: acp::ToolCallId,
|
||||
pub message: String,
|
||||
pub schema: acp::ElicitationSchema,
|
||||
pub response: oneshot::Sender<acp::CreateElicitationResponse>,
|
||||
}
|
||||
|
||||
fn auto_resolve_permission_outcome(
|
||||
options: &acp_thread::PermissionOptions,
|
||||
is_allow: bool,
|
||||
@@ -2151,6 +2165,8 @@ impl Thread {
|
||||
));
|
||||
self.add_tool(WebSearchTool);
|
||||
|
||||
self.add_tool(AskUserTool);
|
||||
|
||||
self.add_tool(DiagnosticsTool::new(self.project.clone()));
|
||||
|
||||
let code_action_store: CodeActionStore = cx.new(|_cx| None);
|
||||
@@ -6376,6 +6392,43 @@ impl ToolCallEventStream {
|
||||
})
|
||||
}
|
||||
|
||||
/// Requests structured input from the user via an elicitation form and
|
||||
/// returns the raw response (accept with content, decline, or cancel).
|
||||
///
|
||||
/// Unlike [`Self::prompt_for_decision`], which presents a fixed set of
|
||||
/// buttons, this renders a form from the provided JSON schema — so callers
|
||||
/// can offer free-text fields, single-select enums, and typed inputs. The
|
||||
/// session scope is attached by the connection layer.
|
||||
pub fn request_elicitation(
|
||||
&self,
|
||||
message: String,
|
||||
schema: acp::ElicitationSchema,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<acp::CreateElicitationResponse>> {
|
||||
let stream = self.stream.clone();
|
||||
let tool_use_id = self.tool_use_id.clone();
|
||||
cx.spawn(async move |_cx| {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
if let Err(error) =
|
||||
stream
|
||||
.0
|
||||
.unbounded_send(Ok(ThreadEvent::Elicitation(ElicitationRequest {
|
||||
tool_call_id: acp::ToolCallId::new(tool_use_id.to_string()),
|
||||
message,
|
||||
schema,
|
||||
response: response_tx,
|
||||
})))
|
||||
{
|
||||
log::error!("Failed to send elicitation request: {error}");
|
||||
return Err(anyhow!("Failed to send elicitation request: {error}"));
|
||||
}
|
||||
|
||||
response_rx
|
||||
.await
|
||||
.map_err(|_| anyhow!("elicitation channel closed"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Prompts the user for authorization.
|
||||
///
|
||||
/// When `check_settings` is `Some`, this gate is settings-driven: the
|
||||
@@ -6634,6 +6687,15 @@ impl ToolCallEventStreamReceiver {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn expect_elicitation(&mut self) -> ElicitationRequest {
|
||||
let event = self.0.next().await;
|
||||
if let Some(Ok(ThreadEvent::Elicitation(request))) = event {
|
||||
request
|
||||
} else {
|
||||
panic!("Expected Elicitation but got: {:?}", event);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn expect_update_fields(&mut self) -> acp::ToolCallUpdateFields {
|
||||
let event = self.0.next().await;
|
||||
if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod apply_code_action_tool;
|
||||
mod ask_user_tool;
|
||||
mod context_server_registry;
|
||||
mod copy_path_tool;
|
||||
mod create_directory_tool;
|
||||
@@ -70,6 +71,7 @@ where
|
||||
}
|
||||
|
||||
pub use apply_code_action_tool::*;
|
||||
pub use ask_user_tool::*;
|
||||
pub use context_server_registry::*;
|
||||
pub use copy_path_tool::*;
|
||||
pub use create_directory_tool::*;
|
||||
@@ -194,6 +196,7 @@ macro_rules! tools {
|
||||
// it never offers a tool the agent can't actually use.
|
||||
tools! {
|
||||
ApplyCodeActionTool,
|
||||
AskUserTool,
|
||||
CopyPathTool,
|
||||
CreateDirectoryTool,
|
||||
CreateThreadTool,
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{AgentTool, ToolCallEventStream, ToolInput};
|
||||
use agent_client_protocol::schema::v1 as acp;
|
||||
use anyhow::Result;
|
||||
use gpui::{App, SharedString, Task};
|
||||
use language_model::LanguageModelToolResultContent;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The single-select field key used when presenting a fixed list of options.
|
||||
const CHOICE_FIELD: &str = "choice";
|
||||
/// The free-text field key used when the user may type their own answer.
|
||||
const OTHER_FIELD: &str = "other";
|
||||
|
||||
/// Ask the user a question, presenting selectable options and/or a free-text
|
||||
/// field.
|
||||
///
|
||||
/// Use this when you need a decision or a piece of information from the user
|
||||
/// mid-task — for example choosing between implementation approaches, confirming
|
||||
/// an assumption, or supplying a value you can't infer. The question is rendered
|
||||
/// as a small form in the conversation, and the user's answer is returned to you
|
||||
/// verbatim.
|
||||
///
|
||||
/// You control the shape of the answer:
|
||||
/// - Provide `options` (two or more) to present clickable choices.
|
||||
/// - Set `allow_free_text` to `true` to let the user type their own answer. Do
|
||||
/// this when the listed options might not be exhaustive, or when you want a
|
||||
/// free-form reply with no preset options at all.
|
||||
///
|
||||
/// You must provide either at least two `options`, or set `allow_free_text` to
|
||||
/// `true` (or both). If both are supplied, the user may pick an option or type
|
||||
/// their own answer, and a typed answer takes precedence.
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AskUserToolInput {
|
||||
/// The question to ask the user. Keep it short and specific.
|
||||
pub question: String,
|
||||
/// The answer choices to present as selectable options. Each entry is the
|
||||
/// exact label shown, and is returned verbatim when chosen. Provide at
|
||||
/// least two options, or leave empty and set `allow_free_text` to `true`.
|
||||
#[serde(default)]
|
||||
pub options: Vec<String>,
|
||||
/// Whether the user may type their own free-form answer instead of (or in
|
||||
/// addition to) picking an option. Set to `true` when the options may not
|
||||
/// be exhaustive, or when you want a free-form reply.
|
||||
#[serde(default)]
|
||||
pub allow_free_text: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum AskUserToolOutput {
|
||||
Answered { selected: String },
|
||||
Error { error: String },
|
||||
}
|
||||
|
||||
impl From<AskUserToolOutput> for LanguageModelToolResultContent {
|
||||
fn from(value: AskUserToolOutput) -> Self {
|
||||
match value {
|
||||
AskUserToolOutput::Answered { selected } => {
|
||||
format!("The user selected: {selected}").into()
|
||||
}
|
||||
AskUserToolOutput::Error { error } => error.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AskUserTool;
|
||||
|
||||
impl AgentTool for AskUserTool {
|
||||
type Input = AskUserToolInput;
|
||||
type Output = AskUserToolOutput;
|
||||
|
||||
const NAME: &'static str = "ask_user";
|
||||
|
||||
fn kind() -> acp::ToolKind {
|
||||
acp::ToolKind::Other
|
||||
}
|
||||
|
||||
fn initial_title(
|
||||
&self,
|
||||
input: Result<Self::Input, serde_json::Value>,
|
||||
_cx: &mut App,
|
||||
) -> SharedString {
|
||||
match input {
|
||||
Ok(input) if !input.question.is_empty() => SharedString::from(input.question),
|
||||
_ => "Asking a question".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
input: ToolInput<Self::Input>,
|
||||
event_stream: ToolCallEventStream,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Self::Output, Self::Output>> {
|
||||
cx.spawn(async move |cx| {
|
||||
let input = input
|
||||
.recv()
|
||||
.await
|
||||
.map_err(|error| AskUserToolOutput::Error {
|
||||
error: error.to_string(),
|
||||
})?;
|
||||
|
||||
if !input.allow_free_text && input.options.len() < 2 {
|
||||
return Err(AskUserToolOutput::Error {
|
||||
error: "The `ask_user` tool needs at least two `options`, or \
|
||||
`allow_free_text` set to true. Ask an open-ended question \
|
||||
in prose instead."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let schema = build_schema(&input.options, input.allow_free_text);
|
||||
|
||||
let prompt = cx
|
||||
.update(|cx| event_stream.request_elicitation(input.question.clone(), schema, cx));
|
||||
let response = prompt.await.map_err(|error| AskUserToolOutput::Error {
|
||||
error: error.to_string(),
|
||||
})?;
|
||||
|
||||
let selected = match response.action {
|
||||
acp::ElicitationAction::Accept(accept) => {
|
||||
let content = accept.content.unwrap_or_default();
|
||||
// A typed answer takes precedence over a picked option.
|
||||
string_field(&content, OTHER_FIELD)
|
||||
.or_else(|| string_field(&content, CHOICE_FIELD))
|
||||
.ok_or_else(|| AskUserToolOutput::Error {
|
||||
error: "The user submitted the form without providing an answer."
|
||||
.to_string(),
|
||||
})?
|
||||
}
|
||||
acp::ElicitationAction::Decline => {
|
||||
return Err(AskUserToolOutput::Error {
|
||||
error: "The user declined to answer the question.".to_string(),
|
||||
});
|
||||
}
|
||||
acp::ElicitationAction::Cancel => {
|
||||
return Err(AskUserToolOutput::Error {
|
||||
error: "The user cancelled the question without answering.".to_string(),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
return Err(AskUserToolOutput::Error {
|
||||
error: "The question was dismissed without an answer.".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
event_stream.update_fields(
|
||||
acp::ToolCallUpdateFields::new().title(format!("Answered: {selected}")),
|
||||
);
|
||||
|
||||
Ok(AskUserToolOutput::Answered { selected })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the elicitation form schema for a question.
|
||||
///
|
||||
/// - Presenting a fixed list of options adds a required single-select field,
|
||||
/// unless free text is also allowed (in which case picking is optional).
|
||||
/// - Allowing free text adds a text field, required only when there are no
|
||||
/// options to choose from.
|
||||
fn build_schema(options: &[String], allow_free_text: bool) -> acp::ElicitationSchema {
|
||||
let mut schema = acp::ElicitationSchema::new();
|
||||
|
||||
if !options.is_empty() {
|
||||
let enum_options = options
|
||||
.iter()
|
||||
.map(|label| acp::EnumOption::new(label.clone(), label.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
schema = schema.property(
|
||||
CHOICE_FIELD,
|
||||
acp::StringPropertySchema::new()
|
||||
.title("Choose an option")
|
||||
.one_of(enum_options),
|
||||
!allow_free_text,
|
||||
);
|
||||
}
|
||||
|
||||
if allow_free_text {
|
||||
let title = if options.is_empty() {
|
||||
"Your answer"
|
||||
} else {
|
||||
"Or type your own answer"
|
||||
};
|
||||
schema = schema.property(
|
||||
OTHER_FIELD,
|
||||
acp::StringPropertySchema::new().title(title),
|
||||
options.is_empty(),
|
||||
);
|
||||
}
|
||||
|
||||
schema
|
||||
}
|
||||
|
||||
/// Extracts a non-empty string value for `key` from an elicitation response.
|
||||
fn string_field(
|
||||
content: &BTreeMap<String, acp::ElicitationContentValue>,
|
||||
key: &str,
|
||||
) -> Option<String> {
|
||||
match content.get(key) {
|
||||
Some(acp::ElicitationContentValue::String(value)) if !value.is_empty() => {
|
||||
Some(value.clone())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
fn accept_with(
|
||||
entries: impl IntoIterator<Item = (&'static str, &'static str)>,
|
||||
) -> acp::CreateElicitationResponse {
|
||||
let content: BTreeMap<String, acp::ElicitationContentValue> = entries
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key.to_string(),
|
||||
acp::ElicitationContentValue::String(value.to_string()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
|
||||
acp::ElicitationAcceptAction::new().content(content),
|
||||
))
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_ask_user_returns_selected_option(cx: &mut TestAppContext) {
|
||||
let (event_stream, mut event_rx) = ToolCallEventStream::test();
|
||||
let tool = Arc::new(AskUserTool);
|
||||
let task = cx.update(|cx| {
|
||||
tool.run(
|
||||
ToolInput::resolved(AskUserToolInput {
|
||||
question: "Which approach?".into(),
|
||||
options: vec!["Approach A".into(), "Approach B".into()],
|
||||
allow_free_text: false,
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let request = event_rx.expect_elicitation().await;
|
||||
assert_eq!(request.message, "Which approach?");
|
||||
assert!(request.schema.properties.contains_key(CHOICE_FIELD));
|
||||
assert!(!request.schema.properties.contains_key(OTHER_FIELD));
|
||||
|
||||
request
|
||||
.response
|
||||
.send(accept_with([(CHOICE_FIELD, "Approach B")]))
|
||||
.unwrap();
|
||||
|
||||
match task.await {
|
||||
Ok(AskUserToolOutput::Answered { selected }) => assert_eq!(selected, "Approach B"),
|
||||
other => panic!("expected an answer, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_ask_user_free_text_answer(cx: &mut TestAppContext) {
|
||||
let (event_stream, mut event_rx) = ToolCallEventStream::test();
|
||||
let tool = Arc::new(AskUserTool);
|
||||
let task = cx.update(|cx| {
|
||||
tool.run(
|
||||
ToolInput::resolved(AskUserToolInput {
|
||||
question: "What should I name it?".into(),
|
||||
options: vec![],
|
||||
allow_free_text: true,
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let request = event_rx.expect_elicitation().await;
|
||||
assert!(request.schema.properties.contains_key(OTHER_FIELD));
|
||||
assert!(!request.schema.properties.contains_key(CHOICE_FIELD));
|
||||
|
||||
request
|
||||
.response
|
||||
.send(accept_with([(OTHER_FIELD, "widget")]))
|
||||
.unwrap();
|
||||
|
||||
match task.await {
|
||||
Ok(AskUserToolOutput::Answered { selected }) => assert_eq!(selected, "widget"),
|
||||
other => panic!("expected an answer, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_ask_user_free_text_takes_precedence(cx: &mut TestAppContext) {
|
||||
let (event_stream, mut event_rx) = ToolCallEventStream::test();
|
||||
let tool = Arc::new(AskUserTool);
|
||||
let task = cx.update(|cx| {
|
||||
tool.run(
|
||||
ToolInput::resolved(AskUserToolInput {
|
||||
question: "Pick or type".into(),
|
||||
options: vec!["A".into(), "B".into()],
|
||||
allow_free_text: true,
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let request = event_rx.expect_elicitation().await;
|
||||
assert!(request.schema.properties.contains_key(CHOICE_FIELD));
|
||||
assert!(request.schema.properties.contains_key(OTHER_FIELD));
|
||||
|
||||
request
|
||||
.response
|
||||
.send(accept_with([(CHOICE_FIELD, "A"), (OTHER_FIELD, "custom")]))
|
||||
.unwrap();
|
||||
|
||||
match task.await {
|
||||
Ok(AskUserToolOutput::Answered { selected }) => assert_eq!(selected, "custom"),
|
||||
other => panic!("expected the typed answer, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_ask_user_decline_is_an_error(cx: &mut TestAppContext) {
|
||||
let (event_stream, mut event_rx) = ToolCallEventStream::test();
|
||||
let tool = Arc::new(AskUserTool);
|
||||
let task = cx.update(|cx| {
|
||||
tool.run(
|
||||
ToolInput::resolved(AskUserToolInput {
|
||||
question: "Proceed?".into(),
|
||||
options: vec!["Yes".into(), "No".into()],
|
||||
allow_free_text: false,
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let request = event_rx.expect_elicitation().await;
|
||||
request
|
||||
.response
|
||||
.send(acp::CreateElicitationResponse::new(
|
||||
acp::ElicitationAction::Decline,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
match task.await {
|
||||
Err(AskUserToolOutput::Error { error }) => {
|
||||
assert!(error.contains("declined"), "got: {error}");
|
||||
}
|
||||
other => panic!("expected an error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_ask_user_requires_options_or_free_text(cx: &mut TestAppContext) {
|
||||
let (event_stream, _event_rx) = ToolCallEventStream::test();
|
||||
let tool = Arc::new(AskUserTool);
|
||||
let result = cx
|
||||
.update(|cx| {
|
||||
tool.run(
|
||||
ToolInput::resolved(AskUserToolInput {
|
||||
question: "Proceed?".into(),
|
||||
options: vec!["Only one".into()],
|
||||
allow_free_text: false,
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(AskUserToolOutput::Error { error }) => {
|
||||
assert!(error.contains("at least two"), "got: {error}");
|
||||
}
|
||||
other => panic!("expected an error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1404,6 +1404,9 @@ mod tests {
|
||||
const EXCLUDED_TOOLS: &[&str] = &[
|
||||
// Read-only / low-risk tools that don't call decide_permission_from_settings
|
||||
"apply_code_action",
|
||||
// Interactive prompt: always asks the user via prompt_for_decision,
|
||||
// so it has no settings-driven permission rules.
|
||||
"ask_user",
|
||||
"diagnostics",
|
||||
"find_path",
|
||||
"find_references",
|
||||
|
||||
Reference in new issue
Block a user