diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs index db43c1be..3201ab9d 100644 --- a/crates/botloftd/src/approvals/mod.rs +++ b/crates/botloftd/src/approvals/mod.rs @@ -15,7 +15,7 @@ use botloft_core::chat::{ use botloft_core::command::tool_explanation; use botloft_core::ids::{ApprovalId, BotId, ChatItemId}; use botloft_core::protocol::{ - AllowScope, Approval, ApprovalItem, ApprovalStatus, ApprovalsAnswerParams, ChatBody, + AllowScope, Approval, ApprovalItem, ApprovalStatus, ApprovalsAnswerParams, ChatBody, ChatItem, }; use botloft_store::ApprovalRecord; use serde_json::Value; @@ -103,12 +103,7 @@ pub(crate) async fn ask_with( tool_use_id: &str, opened: impl FnOnce(&ApprovalId), ) -> Option { - let pending = open(daemon, bot, tool_name, input, tool_use_id)?; - let (answer, waiting) = oneshot::channel(); - daemon - .approvals - .lock() - .insert(pending.record.approval.id.clone(), answer); + let (pending, waiting) = open(daemon, bot, tool_name, input, tool_use_id)?; opened(&pending.record.approval.id); daemon.supervisor.approval_opened(bot, generation); let mut guard = Guard { @@ -220,14 +215,15 @@ struct Pending { record: ApprovalRecord, } -/// Saves the request and puts it in the chat. +/// Saves the request, waits for its answer and puts it in the chat, in +/// that order: the owner may answer as soon as the chat shows it. fn open( daemon: &Daemon, bot: &BotId, tool_name: &str, input: &Value, tool_use_id: &str, -) -> Option { +) -> Option<(Pending, oneshot::Receiver)> { let now = daemon.clock.now_ms(); let approval = Approval { id: ApprovalId::generate(), @@ -245,17 +241,35 @@ fn open( tool_explanation(tool_name, input), always::scope_of(tool_name, input), ); - let item = items::add(daemon, bot, ChatBody::Approval(shown))?; + let item = ChatItem { + id: ChatItemId::generate(), + bot_id: bot.clone(), + body: ChatBody::Approval(shown), + created_at: now, + updated_at: now, + }; let record = ApprovalRecord { approval, - chat_item_id: item.id, + chat_item_id: item.id.clone(), tool_use_id: tool_use_id.to_owned(), }; - if let Err(err) = daemon.store().insert_approval(&record) { - warn!(bot = %bot, "could not save an approval: {err}"); - return None; + { + let store = daemon.store(); + let saved = store + .insert_chat_item(&item) + .and_then(|()| store.insert_approval(&record)); + if let Err(err) = saved { + warn!(bot = %bot, "could not save an approval: {err}"); + return None; + } } - Some(Pending { record }) + let (answer, waiting) = oneshot::channel(); + daemon + .approvals + .lock() + .insert(record.approval.id.clone(), answer); + items::announce(daemon, item); + Some((Pending { record }, waiting)) } fn item_of( diff --git a/crates/botloftd/tests/approval_order.rs b/crates/botloftd/tests/approval_order.rs new file mode 100644 index 00000000..f6f1080f --- /dev/null +++ b/crates/botloftd/tests/approval_order.rs @@ -0,0 +1,65 @@ +//! A request for the owner is saved and waited on before the chat shows it +//! (spec 10.1): an answer sent the moment it shows always lands. + +mod common; + +use botloft_core::protocol::{ApprovalsAnswerParams, BotState, ChatBody}; +use botloftd::state::Event; +use common::TestDaemon; +use common::mcp::Mcp; +use serde_json::json; + +#[tokio::test(flavor = "multi_thread")] +async fn an_answer_the_moment_the_request_shows_lands() { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let lead = json!({ "name": "Chefe", "role": "Leads", "instructions": "Build the site" }); + let crew = app + .call("crews.create", json!({ "name": "Site", "lead": lead })) + .await + .expect("crew"); + let bots = app + .call("bots.list", json!({ "crewId": crew["id"] })) + .await + .expect("bots"); + let chief = bots[0].clone(); + let process = t.process_of(&chief).await; + t.until_state(&chief, BotState::Idle).await; + let mut mcp = Mcp::new(t.addr, process.env("BOTLOFT_BOT_TOKEN").expect("token")); + + for round in 0..20 { + let mut events = t.daemon.subscribe(); + let suggestion = json!({ + "name": format!("Designer {round}"), + "role": "Draws each page", + "instructions": "Design the pages.", + "reason": "The site needs a look.", + }); + let suggesting = tokio::spawn({ + let mut mcp = Mcp::new(mcp.addr, mcp.token.clone()); + async move { mcp.tool("suggest_bot", suggestion).await } + }); + let approval_id = loop { + if let Ok(Event::ChatItem(changed)) = events.recv().await + && let ChatBody::Approval(item) = changed.item.body + { + break item.approval_id; + } + }; + // No wait at all between seeing it and answering. + let answer: ApprovalsAnswerParams = + serde_json::from_value(json!({ "approvalId": approval_id, "allow": false })) + .expect("params"); + botloftd::approvals::answer(&t.daemon, answer).expect("the request is there"); + // And the bot hears it at once, not when the request runs out. + let refused = tokio::time::timeout(std::time::Duration::from_secs(5), suggesting) + .await + .expect("the bot heard the answer") + .expect("task") + .expect("tool"); + assert_eq!(refused["created"], false, "{refused}"); + } + mcp.tool("crew_roster", json!({})) + .await + .expect("still fine"); +} diff --git a/docs/spec.md b/docs/spec.md index f76ea233..4911cdcb 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -576,7 +576,7 @@ Endereçamento só dentro da crew. Bot não enxerga bots nem tasks de outras cre Com `--permission-prompt-tool mcp__botloft__permission_prompt`, toda ferramenta que precisaria de permissão chama a tool `permission_prompt`. A entrada é `{tool_name, input, tool_use_id}` (documentado; visto com 2.1.284). -1. O daemon grava uma `approval` (`pending`) e o item `approval` no chat, e passa o bot para `needs_approval`. +1. O daemon grava uma `approval` (`pending`) e o item `approval` no chat, e passa o bot para `needs_approval`. A ordem importa: grava o item e o pedido, passa a esperar a resposta e só então avisa o app (`chat.item`). Avisando antes, um app rápido respondia a um pedido que ainda não estava no banco (`not found`, visto no CI do Linux), ou a resposta chegava antes da espera e o bot só a ouvia quando o pedido expirava. 2. A chamada HTTP fica aberta até o dono responder (`approvals.answer {approvalId, allow, note?}`) ou até `approval_timeout_minutes`, que o dono escolhe nas Configurações (15 min a 8 h no app; o daemon aceita de 1 min a 1 dia). O `timeout` do `mcp.json` (10) só é lido quando o Claude Code inicia: aumentada a espera, cada bot reinicia quando nada estiver em andamento; diminuída, o daemon responde antes e nada reinicia. 3. Resposta ao Claude Code, em texto JSON: - Permitir: `{"behavior":"allow","updatedInput":}`.