Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 29 additions & 15 deletions crates/botloftd/src/approvals/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -103,12 +103,7 @@ pub(crate) async fn ask_with(
tool_use_id: &str,
opened: impl FnOnce(&ApprovalId),
) -> Option<Answer> {
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 {
Expand Down Expand Up @@ -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<Pending> {
) -> Option<(Pending, oneshot::Receiver<Decision>)> {
let now = daemon.clock.now_ms();
let approval = Approval {
id: ApprovalId::generate(),
Expand All @@ -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(
Expand Down
65 changes: 65 additions & 0 deletions crates/botloftd/tests/approval_order.rs
Original file line number Diff line number Diff line change
@@ -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");
}
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":<input original>}`.
Expand Down
Loading