Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ These bindings are active when Coven Code is displaying a yes/no confirmation pr
| `Enter` | defaultAction | Accept the highlighted default option |
| `Escape` | cancel | Cancel the prompt and deny the action |

In the Next renderer (`COVEN_CODE_UI=next`), a tool permission request opens with **No, deny** highlighted, so `Enter` on a new request denies. Keys other than `Escape` are ignored until 400 ms pass with no key pressed. Each ignored key restarts that wait, so typing meant for the prompt or a held key cannot approve a request. `Escape` always denies immediately.

---

### Inspector Context (Next renderer)
Expand Down
2 changes: 2 additions & 0 deletions src-rust/crates/acp/src/diagnostic_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ async fn permission_reply(reply: Value) -> PermissionDecision {
let (tx, rx) = tokio::sync::oneshot::channel();
let pending = PendingPermissionRequest {
tool_use_id: "fixture-tool".into(),
call_id: None,
reason: "fixture approval".into(),
decision_tx: Some(tx),
request: PermissionRequest {
Expand Down Expand Up @@ -309,6 +310,7 @@ fn permission_io_error_is_denied_without_logging_error_contents() {
let (tx, rx) = tokio::sync::oneshot::channel();
let pending = PendingPermissionRequest {
tool_use_id: "fixture-tool".into(),
call_id: None,
reason: "fixture approval".into(),
decision_tx: Some(tx),
request: PermissionRequest {
Expand Down
2 changes: 2 additions & 0 deletions src-rust/crates/acp/src/permission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pub async fn forward_pending(
) {
let PendingPermissionRequest {
tool_use_id,
call_id: _,
request,
reason,
decision_tx,
Expand Down Expand Up @@ -345,6 +346,7 @@ mod tests {
let (tx, rx) = tokio::sync::oneshot::channel();
store.lock().queue.push_back(PendingPermissionRequest {
tool_use_id: id.into(),
call_id: None,
request: permission_request("Bash", "fixture", None, None, false),
reason: "fixture".into(),
decision_tx: Some(tx),
Expand Down
57 changes: 50 additions & 7 deletions src-rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2035,16 +2035,14 @@ fn handle_bridge_permission_response(
pending_permissions: &mut claurst_tools::PendingPermissionStore,
tool_use_id: &str,
response: claurst_bridge::PermissionResponseKind,
) {
) -> Option<(String, bool)> {
if !dialog
.as_ref()
.is_some_and(|pr| pr.tool_use_id == tool_use_id)
{
return;
return None;
}
let Some(mut pending) = pending_permissions.waiting.remove(tool_use_id) else {
return;
};
let mut pending = pending_permissions.waiting.remove(tool_use_id)?;

use claurst_bridge::PermissionResponseKind;
use claurst_core::permissions::PermissionDecision;
Expand All @@ -2055,10 +2053,13 @@ fn handle_bridge_permission_response(
}
PermissionResponseKind::Deny => PermissionDecision::Deny,
};
let allowed = matches!(decision, PermissionDecision::Allow);
if let Some(tx) = pending.decision_tx.take() {
let _ = tx.send(decision);
}
*dialog = None;
// The decision belongs to the model call that asked, when known.
Some((pending.call_id.unwrap_or(pending.tool_use_id), allowed))
}

fn permission_request_from_core(
Expand Down Expand Up @@ -4067,6 +4068,12 @@ async fn run_interactive(
}
_ => claurst_core::permissions::PermissionDecision::Allow,
};
// Shown beside the tool's execution state; an
// allowed call is not a successful one.
app.record_permission_decision(
pending.call_id.as_deref().unwrap_or(&tool_use_id),
selected_key != Some('n'),
);

if let Some(manager) = tool_ctx.permission_manager.as_ref() {
if let Ok(mut manager) = manager.lock() {
Expand Down Expand Up @@ -4221,7 +4228,16 @@ async fn run_interactive(
match reevaluated {
Some(claurst_core::permissions::PermissionDecision::Ask { .. }) | None => {
let tool_use_id = pending.tool_use_id.clone();
app.permission_request = Some(permission_request_from_core(&pending));
let request = permission_request_from_core(&pending);
// Next guards approvals against stray keys (#411);
// Classic keeps its existing dialog behavior.
app.permission_request = Some(
if app.renderer == claurst_tui::application::UiRenderer::Next {
request.guarded()
} else {
request
},
);
pending_permissions
.lock()
.waiting
Expand Down Expand Up @@ -4514,6 +4530,7 @@ async fn run_interactive(
&& app.renderer == claurst_tui::application::UiRenderer::Next
{
app.note_cancellation_sent();
app.interrupt_tool_rows();
}
app.is_streaming = false;
app.status_message = Some("Cancelled by remote control.".to_string());
Expand All @@ -4522,12 +4539,15 @@ async fn run_interactive(
tool_use_id,
response,
}) => {
handle_bridge_permission_response(
let decided = handle_bridge_permission_response(
&mut app.permission_request,
&mut pending_permissions.lock(),
&tool_use_id,
response,
);
if let Some((call_id, allowed)) = decided {
app.record_permission_decision(&call_id, allowed);
}
}
Ok(TuiBridgeEvent::SessionNameUpdate { title }) => {
session.title = Some(title.clone());
Expand Down Expand Up @@ -6125,6 +6145,7 @@ mod tests {
(
claurst_tools::PendingPermissionRequest {
tool_use_id: id.into(),
call_id: None,
request: claurst_core::permissions::PermissionRequest {
tool_name: "Write".into(),
description: "Write a file".into(),
Expand Down Expand Up @@ -6165,6 +6186,28 @@ mod tests {
}
}

#[test]
fn bridge_permission_decisions_belong_to_the_model_call_that_asked() {
use claurst_bridge::PermissionResponseKind as Response;

for (call_id, response, expected) in [
(Some("toolu_model"), Response::Allow, ("toolu_model", true)),
(Some("toolu_model"), Response::Deny, ("toolu_model", false)),
(None, Response::AllowSession, ("perm-1", true)),
] {
let (mut pending, _rx) = bridge_permission_fixture("perm-1");
pending.call_id = call_id.map(str::to_string);
let mut dialog = Some(permission_request_from_core(&pending));
let mut store = claurst_tools::PendingPermissionStore::default();
store.waiting.insert("perm-1".into(), pending);

let decided =
handle_bridge_permission_response(&mut dialog, &mut store, "perm-1", response);

assert_eq!(decided, Some((expected.0.to_string(), expected.1)));
}
}

#[test]
fn bridge_permission_response_ignores_other_and_repeated_ids() {
use claurst_bridge::PermissionResponseKind as Response;
Expand Down
5 changes: 3 additions & 2 deletions src-rust/crates/query/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3961,9 +3961,10 @@ async fn execute_tool_blocks_with_hooks(
} else {
let name = p.name.clone();
let input = p.input.clone();
futures::future::Either::Right(async move {
let call_id = p.id.clone();
futures::future::Either::Right(claurst_tools::with_tool_call(call_id, async move {
execute_tool(&name, &input, tools, tool_ctx).await
})
}))
}
})
.collect();
Expand Down
36 changes: 36 additions & 0 deletions src-rust/crates/tools/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,29 @@ pub enum PermissionLevel {
#[derive(Debug)]
pub struct PendingPermissionRequest {
pub tool_use_id: String,
/// The model's tool-use id for the call that asked, when known. Distinct
/// from `tool_use_id`, which keys this request and may be synthetic.
pub call_id: Option<String>,
pub request: claurst_core::permissions::PermissionRequest,
pub reason: String,
pub decision_tx: Option<tokio::sync::oneshot::Sender<PermissionDecision>>,
}

tokio::task_local! {
static CURRENT_TOOL_CALL: String;
}

/// Run `future` as the execution of the model tool call `call_id`, so a
/// permission request it raises can be attributed to that call.
pub async fn with_tool_call<F: std::future::Future>(call_id: String, future: F) -> F::Output {
CURRENT_TOOL_CALL.scope(call_id, future).await
}

/// The model tool call being executed by the current task, if any.
pub fn current_tool_call() -> Option<String> {
CURRENT_TOOL_CALL.try_with(Clone::clone).ok()
}

#[derive(Default)]
pub struct PendingPermissionStore {
pub queue: VecDeque<PendingPermissionRequest>,
Expand Down Expand Up @@ -379,6 +397,7 @@ impl ToolContext {
self.session_id,
self.current_turn.fetch_add(1, Ordering::Relaxed)
),
call_id: current_tool_call(),
request,
reason: interactive_reason.unwrap_or(reason),
decision_tx: Some(tx),
Expand Down Expand Up @@ -799,6 +818,23 @@ pub fn find_tool(name: &str) -> Option<Box<dyn Tool>> {

#[cfg(test)]
mod tests {
#[tokio::test]
async fn each_concurrent_tool_call_sees_its_own_call_id() {
assert_eq!(super::current_tool_call(), None);
let probe = || async {
tokio::task::yield_now().await;
super::current_tool_call()
};
let (first, second) = futures::future::join(
super::with_tool_call("toolu_a".to_string(), probe()),
super::with_tool_call("toolu_b".to_string(), probe()),
)
.await;
assert_eq!(first.as_deref(), Some("toolu_a"));
assert_eq!(second.as_deref(), Some("toolu_b"));
assert_eq!(super::current_tool_call(), None);
}

use super::*;

struct AskPermissionHandler {
Expand Down
Loading
Loading