huskies: merge 1228 story Render agent questions as numbered options in chat protocols without question UI

This commit is contained in:
Huskies Agent
2026-07-19 19:23:16 +00:00
parent 933fb5a54b
commit f4f0981f17
26 changed files with 1361 additions and 4 deletions
+55
View File
@@ -37,6 +37,46 @@ pub struct PermissionForward {
pub response_tx: oneshot::Sender<PermissionDecision>,
}
/// A single selectable choice within a [`QuestionSpec`].
#[derive(Debug, Clone)]
pub struct QuestionOption {
pub label: String,
pub description: String,
}
/// A multiple-choice question forwarded from the MCP `ask_question` tool to a
/// chat transport for rendering as numbered text (story 1228).
#[derive(Debug, Clone)]
pub struct QuestionSpec {
pub header: String,
pub question: String,
pub options: Vec<QuestionOption>,
pub multi_select: bool,
}
/// The user's reply to a forwarded [`QuestionSpec`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QuestionAnswer {
/// 0-based indices into `QuestionSpec::options` the user selected.
Selected(Vec<usize>),
/// Freeform text the user typed instead of selecting a listed option
/// (the always-available "Other" path, AC5).
FreeText(String),
}
/// A question request forwarded from the MCP `ask_question` tool to the
/// active chat transport. The MCP handler blocks on `response_tx` until a
/// chat reply resolves it (or it times out).
///
/// Kept structurally separate from `PermissionForward` / permission-router
/// plumbing (see `service::question_router`) so a reply answering one is
/// never misinterpreted as answering the other (story 1228, AC4).
pub struct QuestionForward {
pub request_id: String,
pub question: QuestionSpec,
pub response_tx: oneshot::Sender<Result<QuestionAnswer, String>>,
}
#[derive(Clone)]
/// Shared application state threaded through all HTTP handlers via Poem's `Data` extractor.
pub struct AppContext {
@@ -56,6 +96,10 @@ pub struct AppContext {
/// `prompt_permission` tool. The MCP handler sends a [`PermissionForward`]
/// and awaits the oneshot response.
pub perm_tx: mpsc::UnboundedSender<PermissionForward>,
/// Sender for questions originating from the MCP `ask_question` tool.
/// The MCP handler sends a [`QuestionForward`] and awaits the oneshot
/// response (story 1228).
pub question_tx: mpsc::UnboundedSender<QuestionForward>,
/// Child process of the QA app launched for manual testing.
/// Only one instance runs at a time.
pub qa_app_process: Arc<std::sync::Mutex<Option<std::process::Child>>>,
@@ -101,6 +145,8 @@ impl AppContext {
let (reconciliation_tx, _) = broadcast::channel(64);
let (perm_tx, perm_rx) = mpsc::unbounded_channel();
let permission_registry = crate::service::permission_router::ResponderRegistry::new();
let (question_tx, question_rx) = mpsc::unbounded_channel();
let question_registry = crate::service::question_router::QuestionResponderRegistry::new();
// Plain `#[test]` fns (no tokio runtime) construct `AppContext` too;
// skip spawning when there's no reactor to spawn onto since those
// tests never exercise the permission plumbing.
@@ -109,6 +155,10 @@ impl AppContext {
perm_rx,
Arc::clone(&permission_registry),
);
crate::service::question_router::spawn_question_router(
question_rx,
Arc::clone(&question_registry),
);
}
let timer_store = Arc::new(TimerStore::load(
project_root.join(".huskies").join("timers.json"),
@@ -130,6 +180,10 @@ impl AppContext {
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
question_registry,
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
),
question_timeout_secs: 120,
status: agents.status_broadcaster(),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
});
@@ -141,6 +195,7 @@ impl AppContext {
watcher_tx,
reconciliation_tx,
perm_tx,
question_tx,
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
bot_shutdown: None,
matrix_shutdown_tx: None,