diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 93109fa94d..7db9a83dd2 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -206,6 +206,12 @@ pub struct AcpClient { /// outside of a goose-native turn — the read loop's steer arm is /// disabled in that case. steer_rx: Option>, + /// Outbound channel to the pool's elicitation servicer, installed per turn + /// when the turn has a channel + owner. The read loop's `elicitation/create` + /// arm hands each parsed form to the servicer (which publishes a question + /// card and awaits the owner's tap) and awaits the ACP response. `None` for + /// heartbeats and owner-less turns — the arm then answers `cancel`. + elicitation_tx: Option>, /// Usage tracker — accumulates cumulative token counts from /// `_goose/unstable/session/update` notifications and computes per-turn /// deltas. Both goose and buzz-agent emit this notification; goose gates @@ -395,6 +401,17 @@ fn build_client_capabilities() -> serde_json::Value { "auth": { "terminal": true }, + // Signal to ACP adapters that Buzz can present form elicitations + // (`elicitation/create`, `mode:"form"`). claude-agent-acp gates its + // built-in `AskUserQuestion` tool on this: without it, the tool is added + // to `disallowedTools` and the model falls back to prose. Buzz handles + // the request by publishing a question card to the channel and awaiting + // the owner's tap (see the `elicitation/create` arm in the read loop and + // the pool's elicitation servicer). Only `form` is advertised — Buzz has + // no URL-elicitation surface. + "elicitation": { + "form": {} + }, // Signal to goose that we handle `_goose/unstable/session/update` // notifications. Without this the custom notification is suppressed // on goose's side and usage data is never emitted. @@ -549,6 +566,7 @@ impl AcpClient { active_run_id: None, steering_supported: false, steer_rx: None, + elicitation_tx: None, goose_usage: UsageTracker::default(), }) } @@ -913,6 +931,26 @@ impl AcpClient { self.steer_rx = None; } + /// Install a per-turn elicitation sender for the pool's servicer. + /// + /// Installed before the prompt for turns that have both a channel and a + /// resolved owner; the read loop's `elicitation/create` arm forwards each + /// parsed form to it. Idempotent replacement (unlike steer): the sender is + /// cheap to overwrite and the servicer task is torn down with the turn. + pub fn install_elicitation_tx( + &mut self, + tx: tokio::sync::mpsc::Sender, + ) { + self.elicitation_tx = Some(tx); + } + + /// Clear any installed elicitation sender. Called by `send_prompt_result` on + /// every exit path so a heartbeat turn never inherits the previous channel + /// turn's servicer. Idempotent. + pub fn clear_elicitation_tx(&mut self) { + self.elicitation_tx = None; + } + /// Returns `true` if no steer receiver is currently installed. /// /// Test-only: used by `pool` tests to assert the post-return invariant @@ -1685,6 +1723,33 @@ impl AcpClient { "session/request_permission" => { self.handle_permission_request(&msg).await?; } + "elicitation/create" => { + // AskUserQuestion (and other form elicitations) + // arrive here. The servicer publishes a question + // card and blocks until the owner taps — a wait + // that is legitimately long (no timeout), so the + // loop is parked on the await and neither the idle + // nor hard deadline can fire mid-wait. Renew both + // afterwards so a settled wait doesn't trip an + // immediate timeout on the next iteration. + let result = self.service_elicitation(&msg).await; + if let Some(id) = msg.get("id").cloned() { + let reply = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + }); + self.write_ndjson(&reply).await?; + } + let renew = Instant::now(); + idle_deadline = renew + idle_timeout; + last_activity_at = renew; + let new_hard = renew + max_duration; + if new_hard > hard_deadline { + hard_deadline = new_hard; + self.current_hard_deadline = Some(new_hard); + } + } other => { // If the unknown message has an id, it's a request expecting a reply. // Silence would cause the agent to hang waiting for a response. @@ -1871,6 +1936,54 @@ impl AcpClient { } } + /// Service an inbound `elicitation/create` request via the pool's + /// elicitation servicer and return the ACP `CreateElicitationResponse` + /// result value. + /// + /// Answers `{ "action": "cancel" }` — the fail-closed outcome that lets the + /// adapter fall back to prose — when there is no servicer installed + /// (heartbeat or owner-less turn), the mode is not `form`, the schema is + /// missing, the form has no questions, or the servicer drops the request + /// (turn cancelled). Only borrows `&self`: the (possibly long) await holds no + /// mutable state, so the caller can renew deadlines afterwards. + async fn service_elicitation(&self, msg: &serde_json::Value) -> serde_json::Value { + let cancel = || serde_json::json!({ "action": "cancel" }); + let Some(tx) = self.elicitation_tx.clone() else { + return cancel(); + }; + // Only form-mode elicitations map to question cards. + if msg.pointer("/params/mode").and_then(|v| v.as_str()) != Some("form") { + return cancel(); + } + let message = msg + .pointer("/params/message") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let Some(schema) = msg.pointer("/params/requestedSchema") else { + return cancel(); + }; + let questions = crate::elicitation::parse_elicitation_form(schema, message); + if questions.is_empty() { + return cancel(); + } + let elicitation_id = msg.get("id").map(|v| v.to_string()).unwrap_or_default(); + let tool_call_id = msg + .pointer("/params/toolCallId") + .and_then(|v| v.as_str()) + .map(str::to_owned); + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + let request = crate::pool::ElicitationBridgeRequest { + questions, + elicitation_id, + tool_call_id, + reply_tx, + }; + if tx.send(request).await.is_err() { + return cancel(); + } + reply_rx.await.unwrap_or_else(|_| cancel()) + } + /// Reject a `session/request_permission` request from the agent. /// /// Buzz has no human permission prompt in this harness, so selecting @@ -2396,6 +2509,19 @@ mod tests { ); } + #[test] + fn advertises_elicitation_form_capability() { + // claude-agent-acp gates its built-in AskUserQuestion tool on + // `clientCapabilities.elicitation.form`; without it the tool is + // disallowed and /interview degrades to prose. This is the switch that + // turns question cards on. + let caps = build_client_capabilities(); + assert!( + caps["elicitation"]["form"].is_object(), + "clientCapabilities.elicitation.form must be advertised; got {caps}" + ); + } + #[test] fn request_has_id_field() { let id: u64 = 42; diff --git a/crates/buzz-acp/src/elicitation.rs b/crates/buzz-acp/src/elicitation.rs new file mode 100644 index 0000000000..926f9a0c54 --- /dev/null +++ b/crates/buzz-acp/src/elicitation.rs @@ -0,0 +1,465 @@ +//! Pure helpers for bridging ACP `elicitation/create` (form mode) to Buzz +//! question-card events and back. +//! +//! Claude Code's built-in `AskUserQuestion` tool surfaces over ACP as an +//! `elicitation/create` request with `mode: "form"` (see +//! `RESEARCH/ACP_ELICITATION_ASKUSERQUESTION.md` in the Buzz workspace). The +//! adapter only enables the tool when the client advertises the +//! `elicitation.form` capability; otherwise it drops `AskUserQuestion` into +//! `disallowedTools` and the model falls back to prose. +//! +//! This module is the pure, testable core of the bridge: +//! - [`parse_elicitation_form`] turns a request's `requestedSchema` into a list +//! of [`ElicitationQuestion`]s (one Buzz card is published per question). +//! - [`parse_card_answer`] reads a Buzz answer event's content JSON. +//! - [`build_elicitation_response`] folds the per-card answers back into the ACP +//! `CreateElicitationResponse` the adapter expects, keyed by the schema's +//! `question_` / `question__custom` property names (a non-empty custom +//! answer wins over the picked option, matching the adapter's own semantics). +//! +//! The async round-trip (publish each card, await the owner's taps) lives in the +//! pool, which owns the relay client and channel context. + +use serde_json::{Map, Value}; + +/// One selectable option within a question. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ElicitationOption { + pub label: String, + pub description: Option, +} + +/// One question parsed from an `elicitation/create` form schema. +/// +/// One Buzz `KIND_ELICITATION_REQUEST` card is published per question. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ElicitationQuestion { + /// Schema property key, e.g. `question_0`. The picked answer is written back + /// under this key and the free-text override under `_custom` — the two + /// key shapes the adapter's `applyAskElicitationResponse` reads. + pub key: String, + /// Short header/title, when the schema supplied one. + pub header: Option, + /// The question prompt. For a single-question form the adapter carries the + /// text in the top-level `message` rather than the field description, so + /// callers pass `message` as the fallback. + pub prompt: String, + /// `true` when the field accepts multiple selections (schema `type: array`). + pub multi_select: bool, + /// `true` when a `_custom` free-text companion field is present. + pub allow_custom: bool, + pub options: Vec, +} + +/// The owner's answer to a single question card (parsed from a +/// `KIND_ELICITATION_RESPONSE` event's content JSON). +#[derive(Debug, Clone, PartialEq)] +pub enum CardAnswer { + /// The owner picked option(s) and/or typed a custom answer. + Accept { + /// Picked label (string) or labels (array), if any. + answer: Option, + /// Free-text override, if the owner used the "Other…" field. + custom: Option, + }, + /// The owner explicitly skipped this question. + Decline, + /// The owner cancelled — aborts the whole tool call. + Cancel, +} + +const CUSTOM_SUFFIX: &str = "_custom"; + +/// Parse an `elicitation/create` form `requestedSchema` into ordered questions. +/// +/// `message` is the request's top-level human-readable message, used as the +/// prompt fallback for a single-question form. Returns an empty vec when the +/// schema has no usable question properties. +pub fn parse_elicitation_form(requested_schema: &Value, message: &str) -> Vec { + let Some(properties) = requested_schema + .get("properties") + .and_then(Value::as_object) + else { + return Vec::new(); + }; + + // Property keys ending in `_custom` are free-text companions, not questions. + let mut questions: Vec = properties + .iter() + .filter(|(key, _)| !key.ends_with(CUSTOM_SUFFIX)) + .map(|(key, schema)| { + let multi_select = schema.get("type").and_then(Value::as_str) == Some("array"); + let header = schema + .get("title") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_owned); + let prompt = schema + .get("description") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| message.to_owned()); + let allow_custom = properties.contains_key(&format!("{key}{CUSTOM_SUFFIX}")); + ElicitationQuestion { + key: key.clone(), + header, + prompt, + multi_select, + allow_custom, + options: parse_options(schema, multi_select), + } + }) + .collect(); + + // Order by the numeric suffix of `question_` so cards are published in + // the model's asked order (a plain string sort would place `_10` before + // `_2`); keys without a numeric suffix keep a stable, lexical fallback. + questions.sort_by(|a, b| { + numeric_suffix(&a.key) + .cmp(&numeric_suffix(&b.key)) + .then_with(|| a.key.cmp(&b.key)) + }); + questions +} + +/// Extract option entries from a question schema. +/// +/// Single-select questions carry options under `oneOf`; multi-select questions +/// nest them under `items.anyOf`. Each option is an `EnumOption` +/// (`{ const, title, description? }`). +fn parse_options(schema: &Value, multi_select: bool) -> Vec { + let raw = if multi_select { + schema.get("items").and_then(|items| items.get("anyOf")) + } else { + schema.get("oneOf") + }; + let Some(entries) = raw.and_then(Value::as_array) else { + return Vec::new(); + }; + entries + .iter() + .filter_map(|opt| { + // Prefer the human `title`; fall back to the machine `const` value. + let label = opt + .get("title") + .and_then(Value::as_str) + .or_else(|| opt.get("const").and_then(Value::as_str))?; + let description = opt + .get("description") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_owned); + Some(ElicitationOption { + label: label.to_owned(), + description, + }) + }) + .collect() +} + +/// Return the trailing integer of a `question_` key, or `u64::MAX` when there +/// is no numeric suffix (so unsuffixed keys sort last, then lexically). +fn numeric_suffix(key: &str) -> u64 { + key.rsplit('_') + .next() + .and_then(|tail| tail.parse::().ok()) + .unwrap_or(u64::MAX) +} + +/// Parse a `KIND_ELICITATION_RESPONSE` content JSON into a [`CardAnswer`]. +/// +/// Shape: `{ "action": "accept" | "decline" | "cancel", "answer"?: string | +/// string[], "custom"?: string }`. Unknown or missing actions are treated as +/// `Cancel` (fail-closed: an unparseable answer aborts rather than inventing a +/// selection). +pub fn parse_card_answer(content: &Value) -> CardAnswer { + match content.get("action").and_then(Value::as_str) { + Some("accept") => { + let answer = content.get("answer").filter(|v| !v.is_null()).cloned(); + let custom = content + .get("custom") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_owned); + CardAnswer::Accept { answer, custom } + } + Some("decline") => CardAnswer::Decline, + _ => CardAnswer::Cancel, + } +} + +/// Fold per-card answers into an ACP `CreateElicitationResponse`. +/// +/// - Any `Cancel` short-circuits the whole response to `{ "action": "cancel" }` +/// (a single cancelled card aborts the tool call). +/// - Otherwise the result is `{ "action": "accept", "content": { … } }`, with +/// each accepted question contributing `content[key]` (the picked value) and, +/// when the owner typed one, `content[key_custom]` (the free-text override). +/// - When every card is declined and none accepted, the response is +/// `{ "action": "decline" }` (the adapter reports the user skipped and the +/// turn continues). +/// +/// `items` pairs each question with its answer, in card order. +pub fn build_elicitation_response(items: &[(ElicitationQuestion, CardAnswer)]) -> Value { + if items + .iter() + .any(|(_, answer)| matches!(answer, CardAnswer::Cancel)) + { + return serde_json::json!({ "action": "cancel" }); + } + + let mut content = Map::new(); + for (question, answer) in items { + if let CardAnswer::Accept { answer, custom } = answer { + if let Some(value) = answer { + content.insert(question.key.clone(), value.clone()); + } + if let Some(text) = custom { + content.insert( + format!("{}{CUSTOM_SUFFIX}", question.key), + Value::String(text.clone()), + ); + } + } + } + + if content.is_empty() { + return serde_json::json!({ "action": "decline" }); + } + serde_json::json!({ "action": "accept", "content": Value::Object(content) }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// The exact single-question shape `askUserQuestionsToCreateRequest` emits: + /// prompt in the top-level `message`, options under `oneOf`, plus a + /// `_custom` companion field. + fn single_question_schema() -> Value { + json!({ + "type": "object", + "properties": { + "question_0": { + "type": "string", + "title": "Weight", + "oneOf": [ + { "const": "QUICK", "title": "QUICK", "description": "hack/throwaway" }, + { "const": "STANDARD", "title": "STANDARD" }, + { "const": "FLAGSHIP", "title": "FLAGSHIP" } + ] + }, + "question_0_custom": { + "type": "string", + "title": "Other", + "description": "Type your own answer instead of choosing…" + } + } + }) + } + + #[test] + fn parses_single_question_with_options_and_custom() { + let questions = parse_elicitation_form(&single_question_schema(), "How heavy is this?"); + assert_eq!(questions.len(), 1); + let q = &questions[0]; + assert_eq!(q.key, "question_0"); + assert_eq!(q.header.as_deref(), Some("Weight")); + // No field description → falls back to the form message. + assert_eq!(q.prompt, "How heavy is this?"); + assert!(!q.multi_select); + assert!(q.allow_custom); + assert_eq!(q.options.len(), 3); + assert_eq!(q.options[0].label, "QUICK"); + assert_eq!(q.options[0].description.as_deref(), Some("hack/throwaway")); + assert_eq!(q.options[1].description, None); + } + + #[test] + fn parses_multi_select_from_items_anyof() { + let schema = json!({ + "type": "object", + "properties": { + "question_0": { + "type": "array", + "description": "Pick any that apply", + "items": { "anyOf": [ + { "const": "A", "title": "A" }, + { "const": "B", "title": "B" } + ] } + } + } + }); + let questions = parse_elicitation_form(&schema, "unused"); + assert_eq!(questions.len(), 1); + assert!(questions[0].multi_select); + assert!(!questions[0].allow_custom); + assert_eq!(questions[0].prompt, "Pick any that apply"); + assert_eq!(questions[0].options.len(), 2); + } + + #[test] + fn orders_questions_by_numeric_suffix_not_lexically() { + let mut props = Map::new(); + for i in [0u32, 2, 10, 1] { + props.insert( + format!("question_{i}"), + json!({ "type": "string", "description": format!("q{i}"), "oneOf": [] }), + ); + } + let schema = json!({ "type": "object", "properties": Value::Object(props) }); + let keys: Vec<_> = parse_elicitation_form(&schema, "m") + .into_iter() + .map(|q| q.key) + .collect(); + assert_eq!( + keys, + ["question_0", "question_1", "question_2", "question_10"] + ); + } + + #[test] + fn empty_or_missing_schema_yields_no_questions() { + assert!(parse_elicitation_form(&json!({}), "m").is_empty()); + assert!(parse_elicitation_form(&json!({ "properties": {} }), "m").is_empty()); + } + + #[test] + fn parse_card_answer_variants() { + assert_eq!( + parse_card_answer(&json!({ "action": "accept", "answer": "STANDARD" })), + CardAnswer::Accept { + answer: Some(json!("STANDARD")), + custom: None + } + ); + assert_eq!( + parse_card_answer(&json!({ "action": "accept", "custom": "my own" })), + CardAnswer::Accept { + answer: None, + custom: Some("my own".to_owned()) + } + ); + // Empty custom string is treated as absent. + assert_eq!( + parse_card_answer(&json!({ "action": "accept", "answer": "X", "custom": "" })), + CardAnswer::Accept { + answer: Some(json!("X")), + custom: None + } + ); + assert_eq!( + parse_card_answer(&json!({ "action": "decline" })), + CardAnswer::Decline + ); + assert_eq!( + parse_card_answer(&json!({ "action": "cancel" })), + CardAnswer::Cancel + ); + // Fail closed on garbage. + assert_eq!(parse_card_answer(&json!({})), CardAnswer::Cancel); + } + + fn q(key: &str) -> ElicitationQuestion { + ElicitationQuestion { + key: key.to_owned(), + header: None, + prompt: "p".to_owned(), + multi_select: false, + allow_custom: true, + options: vec![], + } + } + + #[test] + fn builds_accept_response_keyed_by_schema_property() { + let items = vec![( + q("question_0"), + CardAnswer::Accept { + answer: Some(json!("STANDARD")), + custom: None, + }, + )]; + assert_eq!( + build_elicitation_response(&items), + json!({ "action": "accept", "content": { "question_0": "STANDARD" } }) + ); + } + + #[test] + fn custom_answer_is_emitted_under_custom_key() { + let items = vec![( + q("question_0"), + CardAnswer::Accept { + answer: None, + custom: Some("bespoke".to_owned()), + }, + )]; + assert_eq!( + build_elicitation_response(&items), + json!({ "action": "accept", "content": { "question_0_custom": "bespoke" } }) + ); + } + + #[test] + fn multi_select_array_answer_passes_through() { + let items = vec![( + q("question_0"), + CardAnswer::Accept { + answer: Some(json!(["A", "B"])), + custom: None, + }, + )]; + assert_eq!( + build_elicitation_response(&items), + json!({ "action": "accept", "content": { "question_0": ["A", "B"] } }) + ); + } + + #[test] + fn any_cancel_short_circuits_to_cancel() { + let items = vec![ + ( + q("question_0"), + CardAnswer::Accept { + answer: Some(json!("A")), + custom: None, + }, + ), + (q("question_1"), CardAnswer::Cancel), + ]; + assert_eq!( + build_elicitation_response(&items), + json!({ "action": "cancel" }) + ); + } + + #[test] + fn all_declined_yields_decline() { + let items = vec![(q("question_0"), CardAnswer::Decline)]; + assert_eq!( + build_elicitation_response(&items), + json!({ "action": "decline" }) + ); + } + + #[test] + fn declined_card_is_omitted_from_multi_question_content() { + let items = vec![ + ( + q("question_0"), + CardAnswer::Accept { + answer: Some(json!("A")), + custom: None, + }, + ), + (q("question_1"), CardAnswer::Decline), + ]; + assert_eq!( + build_elicitation_response(&items), + json!({ "action": "accept", "content": { "question_0": "A" } }) + ); + } +} diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 65c9dd6203..b7252ccb4b 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2,6 +2,7 @@ mod acp; mod config; +mod elicitation; mod engram_fetch; mod filter; mod observer; diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 8430307d9c..5eefa7de84 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -343,6 +343,27 @@ pub struct SteerRequest { pub ack_tx: tokio::sync::oneshot::Sender, } +/// A form elicitation (`elicitation/create`) handed from the ACP read loop to +/// the pool's per-turn elicitation servicer. +/// +/// The servicer publishes one `KIND_ELICITATION_REQUEST` card per question, +/// polls for the owner's `KIND_ELICITATION_RESPONSE` answers, folds them into an +/// ACP `CreateElicitationResponse`, and returns it on `reply_tx`. Dropping +/// `reply_tx` (the servicer is torn down with the turn) makes the read loop +/// answer `cancel`. +pub struct ElicitationBridgeRequest { + /// Questions parsed from the request's `requestedSchema`, in asked order. + /// The top-level form `message` is already folded into each question's + /// prompt during parsing, so it is not carried separately. + pub questions: Vec, + /// The JSON-RPC request id, stringified — carried on the card for tracing. + pub elicitation_id: String, + /// The ACP `toolCallId`, when the adapter supplied one. + pub tool_call_id: Option, + /// Oneshot for the servicer to return the `CreateElicitationResponse` result. + pub reply_tx: tokio::sync::oneshot::Sender, +} + /// Why a mid-turn steer failed, on either transport /// (`_goose/unstable/session/steer` or `_session/steering`). /// @@ -1366,6 +1387,7 @@ fn send_prompt_result( batch: Option, ) { agent.acp.clear_steer_rx(); + agent.acp.clear_elicitation_tx(); let _ = result_tx.send(PromptResult { agent, source, @@ -1968,6 +1990,26 @@ pub async fn run_prompt_task( prompt_label(&source) ); + // Elicitation bridge: for channel turns with a known owner, stand up a + // servicer that answers `elicitation/create` forms (AskUserQuestion) by + // publishing owner-locked question cards and awaiting the tap. Heartbeats + // and owner-less turns get none — the read loop then answers `cancel` and + // the model falls back to prose. The guard aborts the task on every exit + // path; `send_prompt_result` clears the installed sender. + let _elicitation_guard = match (observer_channel_id, ctx.agent_owner_pubkey) { + (Some(cid), Some(owner)) => { + let (elic_tx, elic_rx) = mpsc::channel::(4); + agent.acp.install_elicitation_tx(elic_tx); + Some(AbortOnDrop(tokio::spawn(run_elicitation_servicer( + elic_rx, + ctx.clone(), + cid, + owner, + )))) + } + _ => None, + }; + // When control_rx is Some (channel tasks), wrap the prompt in select! so // the main loop can cancel, interrupt, or rotate it. Heartbeats // (control_rx=None) take the simple await path — they are not controllable. @@ -3719,6 +3761,189 @@ pub(crate) fn build_turn_metric_counts( /// for this turn) or when `owner_pubkey` is unconfigured (no NIP-AO identity). /// Errors are logged at WARN and never surface to the caller — metric /// publishing must never fail a turn. +/// Aborts a spawned task when dropped. Used to tear the elicitation servicer +/// down on every exit path of `run_prompt_task` — a bare `JoinHandle` drop would +/// leave the servicer (and any in-flight poll) running after the turn ended. +struct AbortOnDrop(tokio::task::JoinHandle<()>); + +impl Drop for AbortOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} + +/// Per-turn task: answer `elicitation/create` forms by publishing question cards +/// and awaiting the owner's taps. One task per channel turn, torn down with it. +async fn run_elicitation_servicer( + mut rx: mpsc::Receiver, + ctx: Arc, + channel_id: uuid::Uuid, + owner: nostr::PublicKey, +) { + while let Some(req) = rx.recv().await { + let result = service_elicitation_request(&ctx, channel_id, &owner, &req).await; + let _ = req.reply_tx.send(result); + } +} + +/// Publish one card per question, then poll until the owner has answered every +/// card, and fold the answers into an ACP `CreateElicitationResponse`. +/// +/// No timeout: the owner sets the pace and the task is aborted with the turn if +/// the answer never comes. A publish failure cancels the whole tool call rather +/// than leaving a partially-answerable form. +async fn service_elicitation_request( + ctx: &PromptContext, + channel_id: uuid::Uuid, + owner: &nostr::PublicKey, + req: &ElicitationBridgeRequest, +) -> serde_json::Value { + use crate::elicitation::CardAnswer; + + let mut cards: Vec<(crate::elicitation::ElicitationQuestion, String)> = + Vec::with_capacity(req.questions.len()); + for question in &req.questions { + match publish_question_card(ctx, channel_id, owner, req, question).await { + Some(card_id) => cards.push((question.clone(), card_id)), + None => { + tracing::warn!( + target: "pool::elicitation", + "failed to publish question card — cancelling elicitation {}", + req.elicitation_id + ); + return serde_json::json!({ "action": "cancel" }); + } + } + } + + const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(1500); + let mut answers: std::collections::HashMap = + std::collections::HashMap::new(); + loop { + for (_, card_id) in &cards { + if answers.contains_key(card_id) { + continue; + } + if let Some(answer) = fetch_card_answer(ctx, owner, card_id).await { + answers.insert(card_id.clone(), answer); + } + } + if answers.len() == cards.len() { + break; + } + tokio::time::sleep(POLL_INTERVAL).await; + } + + let items: Vec<(crate::elicitation::ElicitationQuestion, CardAnswer)> = cards + .into_iter() + .map(|(question, card_id)| { + let answer = answers.remove(&card_id).unwrap_or(CardAnswer::Cancel); + (question, answer) + }) + .collect(); + crate::elicitation::build_elicitation_response(&items) +} + +/// Build, sign, and publish a single `KIND_ELICITATION_REQUEST` card, returning +/// its event id (the correlation key the owner's answer references via `#e`). +async fn publish_question_card( + ctx: &PromptContext, + channel_id: uuid::Uuid, + owner: &nostr::PublicKey, + req: &ElicitationBridgeRequest, + question: &crate::elicitation::ElicitationQuestion, +) -> Option { + use nostr::{EventBuilder, Kind, Tag}; + + let options: Vec = question + .options + .iter() + .map(|o| serde_json::json!({ "label": o.label, "description": o.description })) + .collect(); + let content = serde_json::json!({ + "v": 1, + "questionKey": question.key, + "header": question.header, + "prompt": question.prompt, + "multiSelect": question.multi_select, + "allowCustom": question.allow_custom, + "options": options, + "elicitationId": req.elicitation_id, + "toolCallId": req.tool_call_id, + }) + .to_string(); + + let channel_tag = channel_id.to_string(); + let owner_hex = owner.to_hex(); + let tags = vec![ + Tag::parse(["h", &channel_tag]).ok()?, + Tag::parse(["p", &owner_hex]).ok()?, + ]; + let event = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_ELICITATION_REQUEST as u16), + content, + ) + .tags(tags) + .sign_with_keys(&ctx.agent_keys) + .ok()?; + + let card_id = event.id.to_hex(); + const PUBLISH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + match tokio::time::timeout(PUBLISH_TIMEOUT, ctx.rest_client.submit_event(&event)).await { + Ok(Ok(_)) => Some(card_id), + Ok(Err(e)) => { + tracing::warn!(target: "pool::elicitation", "card publish failed: {e}"); + None + } + Err(_) => { + tracing::warn!(target: "pool::elicitation", "card publish timed out"); + None + } + } +} + +/// Query the relay for an owner-authored answer to a single card. Returns the +/// parsed answer, or `None` when none has arrived yet. +async fn fetch_card_answer( + ctx: &PromptContext, + owner: &nostr::PublicKey, + card_id: &str, +) -> Option { + use nostr::{Filter, Kind}; + + let event_id = nostr::EventId::from_hex(card_id).ok()?; + let filter = Filter::new() + .kind(Kind::Custom( + buzz_core::kind::KIND_ELICITATION_RESPONSE as u16, + )) + .author(*owner) + .event(event_id); + + const QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + let value = match tokio::time::timeout(QUERY_TIMEOUT, ctx.rest_client.query(&[filter])).await { + Ok(Ok(v)) => v, + Ok(Err(e)) => { + tracing::debug!(target: "pool::elicitation", "answer query failed: {e}"); + return None; + } + Err(_) => { + tracing::debug!(target: "pool::elicitation", "answer query timed out"); + return None; + } + }; + + // `/query` returns a JSON array of sig-stripped events. Take the first with a + // parseable answer content. + let events = value.as_array()?; + for ev in events { + let content = ev.get("content").and_then(|c| c.as_str())?; + if let Ok(parsed) = serde_json::from_str::(content) { + return Some(crate::elicitation::parse_card_answer(&parsed)); + } + } + None +} + async fn publish_agent_turn_metric( ctx: &PromptContext, usage: Option, diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913..e400527274 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -544,6 +544,28 @@ pub const KIND_MEMBER_REMOVED_NOTIFICATION: u32 = 44101; /// See `docs/nips/NIP-AM.md`. pub const KIND_AGENT_TURN_METRIC: u32 = 44200; +/// An interactive elicitation request — an agent asking the channel a +/// structured, tappable question (the ACP `elicitation/create` form mode, +/// which is how Claude Code's `AskUserQuestion` tool surfaces over ACP). +/// +/// Regular stored channel event authored by the agent, scoped with an `h` tag. +/// `content` is JSON: `{ message, questions: [{ key, header?, prompt?, +/// multiSelect, allowCustom, options: [{ label, description? }] }], +/// toolCallId?, elicitationId }`. A member answers by publishing a +/// [`KIND_ELICITATION_RESPONSE`] that `#e`-references this event. See +/// `RESEARCH/ACP_ELICITATION_ASKUSERQUESTION.md` in the Buzz workspace and +/// the harness bridge in `buzz-acp`. +pub const KIND_ELICITATION_REQUEST: u32 = 44300; + +/// A member's answer to a [`KIND_ELICITATION_REQUEST`]. +/// +/// Regular stored channel event, `h`-scoped, carrying an `e` tag referencing +/// the request event id. `content` is JSON: `{ action: "accept" | "decline" | +/// "cancel", answers?: { : }, custom?: +/// { : } }`. The `buzz-acp` bridge folds the first +/// `accept` back into the ACP `CreateElicitationResponse` for the waiting agent. +pub const KIND_ELICITATION_RESPONSE: u32 = 44301; + // Forum / social (45000–45999) // V1 used addressable range (30001–30003) — wrong. /// A forum post (thread root). @@ -725,6 +747,8 @@ pub const ALL_KINDS: &[u32] = &[ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_AGENT_TURN_METRIC, + KIND_ELICITATION_REQUEST, + KIND_ELICITATION_RESPONSE, KIND_WORKFLOW_DEF, KIND_LONG_FORM, KIND_USER_STATUS, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index cd9f20b5f4..9febe261a2 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -15,25 +15,26 @@ use buzz_core::kind::{ is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, - KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, - KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, - KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, - KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, - KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, - KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, - KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, - KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, - KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, - KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, - KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, - RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_ELICITATION_REQUEST, KIND_ELICITATION_RESPONSE, KIND_EMOJI_LIST, KIND_EMOJI_SET, + KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, + KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, + KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, + KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, + KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, + KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, + KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, + KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, + KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, + KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, + KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, + KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, + KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, + KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, + RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -304,7 +305,12 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), + | KIND_FORUM_COMMENT + // Interactive elicitation: agent-authored question card + member answer. + // Both are ordinary channel message writes; the acp bridge and desktop + // renderer own the interaction semantics. + | KIND_ELICITATION_REQUEST + | KIND_ELICITATION_RESPONSE => Ok(Scope::MessagesWrite), KIND_NIP29_PUT_USER | KIND_NIP29_REMOVE_USER | KIND_NIP29_DELETE_GROUP => { Ok(Scope::AdminChannels) } diff --git a/crates/buzz-test-client/tests/e2e_elicitation.rs b/crates/buzz-test-client/tests/e2e_elicitation.rs new file mode 100644 index 0000000000..7d4dc1b1f7 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_elicitation.rs @@ -0,0 +1,151 @@ +//! End-to-end tests for interactive elicitation question cards +//! (kind:44300 request / kind:44301 answer). +//! +//! Proves, against a live relay, the two things the in-process unit tests +//! cannot: (1) the relay accepts the new kinds (scope registration in +//! `required_scope_for_kind`), and (2) an answer is retrievable via the exact +//! filter the `buzz-acp` elicitation servicer polls with +//! (`kind:44301 & author & #e=`). +//! +//! Requires a running relay. Marked `#[ignore]` so plain `cargo test` skips it. +//! +//! # Running +//! +//! ```text +//! just relay # in another terminal +//! cargo test --test e2e_elicitation -- --ignored +//! # or point elsewhere: +//! RELAY_URL=ws://host:3000 cargo test --test e2e_elicitation -- --ignored +//! ``` + +use std::time::Duration; + +use buzz_test_client::BuzzTestClient; +use nostr::{EventBuilder, Filter, Keys, Kind, Tag}; + +const KIND_ELICITATION_REQUEST: u16 = 44300; +const KIND_ELICITATION_RESPONSE: u16 = 44301; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn sub_id(name: &str) -> String { + format!("e2e-{name}-{}", uuid::Uuid::new_v4()) +} + +/// Build a question card (44300) the way the pool servicer does: a JSON form in +/// `content` and a `p` tag locking it to the owner. +fn build_question_card(agent: &Keys, owner: &Keys) -> nostr::Event { + let content = serde_json::json!({ + "v": 1, + "questionKey": "question_0", + "header": "Weight", + "prompt": "How heavy is this?", + "multiSelect": false, + "allowCustom": true, + "options": [ + { "label": "QUICK", "description": "hack/throwaway" }, + { "label": "STANDARD" }, + { "label": "FLAGSHIP" } + ], + "elicitationId": "1", + }) + .to_string(); + EventBuilder::new(Kind::Custom(KIND_ELICITATION_REQUEST), content) + .tags([Tag::parse(["p", &owner.public_key().to_hex()]).unwrap()]) + .sign_with_keys(agent) + .unwrap() +} + +/// Build an answer (44301) the way the desktop card does: `{action,answer,custom}` +/// in `content` and an `e` tag referencing the card. +fn build_answer(owner: &Keys, card_id: nostr::EventId, answer: &str) -> nostr::Event { + let content = + serde_json::json!({ "action": "accept", "answer": answer, "custom": "" }).to_string(); + EventBuilder::new(Kind::Custom(KIND_ELICITATION_RESPONSE), content) + .tags([Tag::parse(["e", &card_id.to_hex(), "", "reply"]).unwrap()]) + .sign_with_keys(owner) + .unwrap() +} + +/// The relay accepts both new kinds (scope registration works). +#[tokio::test] +#[ignore] +async fn test_elicitation_kinds_accepted() { + let url = relay_url(); + let agent = Keys::generate(); + let owner = Keys::generate(); + + let mut agent_client = BuzzTestClient::connect(&url, &agent) + .await + .expect("agent connect"); + let card = build_question_card(&agent, &owner); + let card_id = card.id; + let ok = agent_client.send_event(card).await.expect("send card"); + assert!(ok.accepted, "relay must accept kind:44300: {}", ok.message); + + let mut owner_client = BuzzTestClient::connect(&url, &owner) + .await + .expect("owner connect"); + let answer = build_answer(&owner, card_id, "STANDARD"); + let ok = owner_client.send_event(answer).await.expect("send answer"); + assert!(ok.accepted, "relay must accept kind:44301: {}", ok.message); + + agent_client.disconnect().await.expect("agent disconnect"); + owner_client.disconnect().await.expect("owner disconnect"); +} + +/// The owner's answer is retrievable via the exact filter the acp servicer polls +/// with — and carries the expected answer content. +#[tokio::test] +#[ignore] +async fn test_answer_retrievable_by_servicer_poll_filter() { + let url = relay_url(); + let agent = Keys::generate(); + let owner = Keys::generate(); + + let mut agent_client = BuzzTestClient::connect(&url, &agent) + .await + .expect("agent connect"); + let card = build_question_card(&agent, &owner); + let card_id = card.id; + let ok = agent_client.send_event(card).await.expect("send card"); + assert!(ok.accepted, "relay must accept card: {}", ok.message); + + let mut owner_client = BuzzTestClient::connect(&url, &owner) + .await + .expect("owner connect"); + let answer = build_answer(&owner, card_id, "FLAGSHIP"); + let answer_id = answer.id; + let ok = owner_client.send_event(answer).await.expect("send answer"); + assert!(ok.accepted, "relay must accept answer: {}", ok.message); + + // The exact filter `pool::fetch_card_answer` builds: kind 44301, authored by + // the owner, `#e` = the card id. + let sid = sub_id("poll"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_ELICITATION_RESPONSE)) + .author(owner.public_key()) + .event(card_id); + agent_client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = agent_client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + let found = events + .iter() + .find(|e| e.id == answer_id) + .expect("servicer poll filter must return the owner's answer"); + let parsed: serde_json::Value = + serde_json::from_str(&found.content).expect("answer content is JSON"); + assert_eq!(parsed["action"], "accept"); + assert_eq!(parsed["answer"], "FLAGSHIP"); + + agent_client.disconnect().await.expect("agent disconnect"); + owner_client.disconnect().await.expect("owner disconnect"); +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b..a919fade0a 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/question-card-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", "**/key-import-reveal.spec.ts", diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 640c12bb75..ea2010c6f5 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -26,6 +26,7 @@ import { KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT, + KIND_ELICITATION_REQUEST, KIND_HUDDLE_STARTED, KIND_DELETION, KIND_NIP29_DELETE_EVENT, @@ -58,6 +59,7 @@ export function isTimelineContentEvent(event: RelayEvent) { event.kind === KIND_JOB_RESULT || event.kind === KIND_JOB_CANCEL || event.kind === KIND_JOB_ERROR || + event.kind === KIND_ELICITATION_REQUEST || event.kind === KIND_HUDDLE_STARTED ); } diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 9f55e712f1..dfd50413a3 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -25,6 +25,7 @@ import { THREAD_REPLY_LINE_WIDTH_REM, } from "@/features/messages/lib/threadTreeLayout"; import { + KIND_ELICITATION_REQUEST, KIND_HUDDLE_STARTED, KIND_STREAM_MESSAGE_DIFF, } from "@/shared/constants/kinds"; @@ -44,6 +45,7 @@ import { MessageActionBar } from "./MessageActionBar"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; +import { QuestionCard } from "./QuestionCard"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -340,6 +342,8 @@ export const MessageRow = React.memo( message={message} /> ); + case KIND_ELICITATION_REQUEST: + return ; default: { const waveMessage = parseWaveMessageContent(message.body); diff --git a/desktop/src/features/messages/ui/QuestionCard.tsx b/desktop/src/features/messages/ui/QuestionCard.tsx new file mode 100644 index 0000000000..6e6d3016a3 --- /dev/null +++ b/desktop/src/features/messages/ui/QuestionCard.tsx @@ -0,0 +1,371 @@ +import * as React from "react"; +import { HelpCircle } from "lucide-react"; +import { toast } from "sonner"; + +import type { TimelineMessage } from "@/features/messages/types"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_ELICITATION_RESPONSE } from "@/shared/constants/kinds"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { Checkbox } from "@/shared/ui/checkbox"; +import { Input } from "@/shared/ui/input"; + +type QuestionCardProps = { + channelId: string | null; + className?: string; + message: TimelineMessage; +}; + +type ElicitationOption = { + label: string; + description?: string; +}; + +type ElicitationRequest = { + questionKey?: string; + header?: string; + prompt?: string; + multiSelect: boolean; + allowCustom: boolean; + options: ElicitationOption[]; +}; + +function parseElicitationRequest(content: string): ElicitationRequest | null { + try { + const parsed = JSON.parse(content) as { + questionKey?: unknown; + header?: unknown; + prompt?: unknown; + multiSelect?: unknown; + allowCustom?: unknown; + options?: unknown; + }; + const rawOptions = Array.isArray(parsed.options) ? parsed.options : []; + const options: ElicitationOption[] = []; + for (const option of rawOptions) { + if ( + option && + typeof option === "object" && + typeof (option as { label?: unknown }).label === "string" + ) { + const label = (option as { label: string }).label; + const description = (option as { description?: unknown }).description; + options.push({ + label, + description: + typeof description === "string" ? description : undefined, + }); + } + } + if (options.length === 0) return null; + return { + questionKey: + typeof parsed.questionKey === "string" ? parsed.questionKey : undefined, + header: typeof parsed.header === "string" ? parsed.header : undefined, + prompt: typeof parsed.prompt === "string" ? parsed.prompt : undefined, + multiSelect: parsed.multiSelect === true, + allowCustom: parsed.allowCustom === true, + options, + }; + } catch { + return null; + } +} + +type AnsweredState = { + answer: string[]; + custom: string; +}; + +function parseResponseContent(content: string): AnsweredState | null { + try { + const parsed = JSON.parse(content) as { + answer?: unknown; + custom?: unknown; + }; + const answer = Array.isArray(parsed.answer) + ? parsed.answer.filter( + (value): value is string => typeof value === "string", + ) + : typeof parsed.answer === "string" && parsed.answer.length > 0 + ? [parsed.answer] + : []; + const custom = typeof parsed.custom === "string" ? parsed.custom : ""; + return { answer, custom }; + } catch { + return null; + } +} + +function getTag(message: TimelineMessage, name: string): string | undefined { + return message.tags?.find((tag) => tag[0] === name)?.[1]; +} + +export function QuestionCard({ + channelId, + className, + message, +}: QuestionCardProps) { + const request = React.useMemo( + () => parseElicitationRequest(message.body), + [message.body], + ); + const ownerPubkey = React.useMemo(() => { + const tag = getTag(message, "p"); + return tag ? normalizePubkey(tag) : null; + }, [message]); + const currentPubkey = useIdentityQuery().data?.pubkey; + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + const isOwner = Boolean( + ownerPubkey && + normalizedCurrentPubkey && + ownerPubkey === normalizedCurrentPubkey, + ); + + const [selected, setSelected] = React.useState>(() => new Set()); + const [customValue, setCustomValue] = React.useState(""); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [answered, setAnswered] = React.useState(null); + + // Detect an existing owner-authored answer referencing this card. Seeds from + // the loaded timeline (any 44301 already present) and subscribes for late + // arrivals, mirroring HuddleAttachment's live-subscription pattern. + React.useEffect(() => { + if (!channelId || !ownerPubkey) return; + + let disposed = false; + let cleanup: (() => void) | null = null; + + function applyResponse(event: RelayEvent) { + if (disposed) return; + if (normalizePubkey(event.pubkey ?? "") !== ownerPubkey) return; + const parsed = parseResponseContent(event.content); + if (parsed) setAnswered(parsed); + } + + relayClient + .subscribeLive( + { + kinds: [KIND_ELICITATION_RESPONSE], + authors: [ownerPubkey], + "#e": [message.id], + limit: 1, + }, + applyResponse, + ) + .then((dispose) => { + if (disposed) { + void dispose(); + return; + } + cleanup = () => void dispose(); + }) + .catch((error) => { + console.error("[QuestionCard] response subscription failed:", error); + }); + + return () => { + disposed = true; + cleanup?.(); + }; + }, [channelId, message.id, ownerPubkey]); + + if (!request) { + return ( +
+
+ + This question card is missing its details. +
+
+ ); + } + + const toggleSelection = (label: string) => { + setSelected((current) => { + const next = new Set(request.multiSelect ? current : []); + if (next.has(label)) { + next.delete(label); + } else { + next.add(label); + } + return next; + }); + }; + + const hasSelection = selected.size > 0; + const trimmedCustom = customValue.trim(); + const canSubmit = + isOwner && + !answered && + !isSubmitting && + (hasSelection || trimmedCustom.length > 0); + + async function handleSubmit() { + if (!channelId || !request) return; + if (isSubmitting || answered) return; + + const selectedLabels = [...selected]; + const custom = customValue.trim(); + if (selectedLabels.length === 0 && custom.length === 0) return; + + const answer: string | string[] = request.multiSelect + ? selectedLabels + : (selectedLabels[0] ?? ""); + + setIsSubmitting(true); + try { + const event = await signRelayEvent({ + kind: KIND_ELICITATION_RESPONSE, + content: JSON.stringify({ + action: "accept", + answer, + custom, + }), + tags: [ + ["h", channelId], + ["e", message.id, "", "reply"], + ], + }); + await relayClient.publishEvent( + event, + "Timed out sending your answer.", + "Failed to send your answer.", + ); + setAnswered({ + answer: request.multiSelect + ? selectedLabels + : selectedLabels.slice(0, 1), + custom, + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to send your answer.", + ); + } finally { + setIsSubmitting(false); + } + } + + const isAnswered = answered !== null; + const interactive = isOwner && !isAnswered; + const answeredLabels = new Set(answered?.answer ?? []); + + return ( +
+
+ +
+ {request.header ? ( +
+ {request.header} +
+ ) : null} + {request.prompt ? ( +
+ {request.prompt} +
+ ) : null} +
+
+ + {!isOwner && !isAnswered ? ( +

+ Question for the owner +

+ ) : null} + +
+ {request.options.map((option) => { + const isSelected = isAnswered + ? answeredLabels.has(option.label) + : selected.has(option.label); + return ( + + ); + })} +
+ + {request.allowCustom ? ( +
+ setCustomValue(event.target.value)} + placeholder="Other…" + value={isAnswered ? (answered?.custom ?? "") : customValue} + /> +
+ ) : null} + + {interactive ? ( +
+ +
+ ) : null} + + {isAnswered ? ( +

Answer submitted

+ ) : null} +
+ ); +} diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index f995a63596..74d30f9d3d 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -33,6 +33,11 @@ export const KIND_FORUM_COMMENT = 45003; export const KIND_APPROVAL_REQUEST = 46010; export const KIND_MEMBER_ADDED_NOTIFICATION = 44100; export const KIND_MEMBER_REMOVED_NOTIFICATION = 44101; +// Interactive "question card" elicitation flow. 44300 is the agent-authored +// question card (renders its own timeline row); 44301 is the human's answer, +// which routes back to the agent and does NOT render its own row. +export const KIND_ELICITATION_REQUEST = 44300; +export const KIND_ELICITATION_RESPONSE = 44301; export const KIND_TYPING_INDICATOR = 20002; export const KIND_HUDDLE_REACTION = 24810; export const KIND_HUDDLE_STARTED = 48100; @@ -97,6 +102,7 @@ export const CHANNEL_EVENT_KINDS = [ KIND_STREAM_MESSAGE_EDIT, // 40003 — message edits KIND_STREAM_MESSAGE_DIFF, // 40008 — message diffs KIND_SYSTEM_MESSAGE, // 40099 — system messages (join, leave, etc.) + KIND_ELICITATION_REQUEST, // 44300 — interactive question card KIND_HUDDLE_STARTED, // 48100 — visible huddle session card KIND_HUDDLE_PARTICIPANT_JOINED, // 48101 — huddle lifecycle overlay KIND_HUDDLE_PARTICIPANT_LEFT, // 48102 — huddle lifecycle overlay @@ -136,6 +142,7 @@ export const CHANNEL_TIMELINE_CONTENT_KINDS = [ KIND_JOB_RESULT, // 43004 KIND_JOB_CANCEL, // 43005 KIND_JOB_ERROR, // 43006 + KIND_ELICITATION_REQUEST, // 44300 — interactive question card (own row) KIND_HUDDLE_STARTED, // 48100 — huddle session card ] as const; diff --git a/desktop/tests/e2e/question-card-screenshots.spec.ts b/desktop/tests/e2e/question-card-screenshots.spec.ts new file mode 100644 index 0000000000..631b4d48f4 --- /dev/null +++ b/desktop/tests/e2e/question-card-screenshots.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +// The mock bridge signs in as this identity, so a card `p`-tagged to it renders +// in the interactive (owner-locked) state with tappable options. +const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8); +const AGENT_PUBKEY = + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; +const CHANNEL_NAME = "engineering"; + +type MockMessageWindow = Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + pubkey?: string; + kind?: number; + extraTags?: string[][]; + }) => { id: string } | undefined; + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + }) => boolean; +}; + +const KIND_ELICITATION_REQUEST = 44300; + +// A single /interview-style question card (one card per question). +const CARD_CONTENT = JSON.stringify({ + v: 1, + questionKey: "question_0", + header: "Project weight", + prompt: "How heavy is this — throwaway, real, or flagship?", + multiSelect: false, + allowCustom: true, + options: [ + { label: "QUICK", description: "hack / throwaway" }, + { label: "STANDARD", description: "a real project" }, + { label: "FLAGSHIP", description: "revenue / public / client-facing" }, + ], +}); + +async function waitForMockLiveSubscription( + page: import("@playwright/test").Page, + channelName: string, +) { + await expect + .poll(() => + page.evaluate( + (name) => + ( + window as MockMessageWindow + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: name }) ?? + false, + channelName, + ), + ) + .toBe(true); +} + +test.describe("interactive question card", () => { + test.use({ viewport: { width: 1280, height: 720 } }); + + test("renders an owner-locked question card with tappable options", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId(`channel-${CHANNEL_NAME}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL_NAME); + await waitForMockLiveSubscription(page, CHANNEL_NAME); + + // The agent posts a 44300 card, locked to the current (owner) identity. + await page.evaluate( + ({ channelName, content, agent, owner, kind }) => { + (window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content, + pubkey: agent, + kind, + extraTags: [["p", owner]], + }); + }, + { + channelName: CHANNEL_NAME, + content: CARD_CONTENT, + agent: AGENT_PUBKEY, + owner: MOCK_IDENTITY_PUBKEY, + kind: KIND_ELICITATION_REQUEST, + }, + ); + + const card = page.getByTestId("question-card"); + await expect(card).toBeVisible(); + await expect(card).toHaveAttribute("data-state", "open"); + // Each option renders as its own tappable button, plus the custom field. + await expect(card.getByRole("button", { name: /QUICK/ })).toBeVisible(); + await expect(card.getByRole("button", { name: /FLAGSHIP/ })).toBeVisible(); + await expect(card.getByPlaceholder("Other…")).toBeVisible(); + + await waitForAnimations(page); + await card.screenshot({ + path: "test-results/screenshots/question-card.png", + }); + }); +});