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
16 changes: 9 additions & 7 deletions crates/ironclaw_gateway/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1175,8 +1175,9 @@ function sendMessage() {
autoResizeTextarea(input);
input.focus();
const requestId = approvalCard.getAttribute('data-request-id');
const threadId = approvalCard.getAttribute('data-thread-id');
if (requestId) {
sendApprovalAction(requestId, action);
sendApprovalAction(requestId, action, threadId);
}
return;
}
Expand Down Expand Up @@ -1416,12 +1417,14 @@ function filterSlashCommands(value) {
}
}

function sendApprovalAction(requestId, action) {
function sendApprovalAction(requestId, action, threadId) {
const card = document.querySelector('.approval-card[data-request-id="' + requestId + '"]');
const targetThreadId = threadId || (card ? card.getAttribute('data-thread-id') : null) || currentThreadId;
apiFetch('/api/chat/gate/resolve', {
method: 'POST',
body: {
request_id: requestId,
thread_id: currentThreadId,
thread_id: targetThreadId,
resolution: action === 'deny' ? 'denied' : 'approved',
always: action === 'always',
},
Expand All @@ -1430,7 +1433,6 @@ function sendApprovalAction(requestId, action) {
});

// Disable buttons and show confirmation on the card
const card = document.querySelector('.approval-card[data-request-id="' + requestId + '"]');
if (card) {
const buttons = card.querySelectorAll('.approval-actions button');
buttons.forEach((btn) => {
Expand Down Expand Up @@ -2172,19 +2174,19 @@ function showApproval(data) {
const approveBtn = document.createElement('button');
approveBtn.className = 'approve';
approveBtn.textContent = I18n.t('approval.approve');
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve'));
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve', cardThreadId));

const denyBtn = document.createElement('button');
denyBtn.className = 'deny';
denyBtn.textContent = I18n.t('approval.deny');
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny'));
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny', cardThreadId));

actions.appendChild(approveBtn);
if (data.allow_always !== false) {
const alwaysBtn = document.createElement('button');
alwaysBtn.className = 'always';
alwaysBtn.textContent = I18n.t('approval.always');
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always', cardThreadId));
actions.appendChild(alwaysBtn);
}
actions.appendChild(denyBtn);
Expand Down
63 changes: 58 additions & 5 deletions src/bridge/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1225,11 +1225,21 @@ pub async fn handle_approval(
.as_ref()
.ok_or_else(|| engine_err("init", "engine state is empty"))?;

// Don't pass the v1 thread_id as a hint — the v1 session uses different
// UUIDs from the engine. The user_id alone is sufficient for single-user
// deployments; ambiguity resolution kicks in for multi-user.
let pending = match resolve_pending_gate_for_user(&state.pending_gates, &message.user_id, None)
.await
// Scope explicit approval replies to the active gateway conversation when
// available so `/approve` cannot resume an unrelated pending gate owned by
// another thread, such as a background routine. Other channels still use
// legacy thread IDs that do not map 1:1 to engine conversation scopes.
let thread_scope = if message.channel == "gateway" {
message.conversation_scope()
} else {
None
};
let pending = match resolve_pending_gate_for_user(
&state.pending_gates,
&message.user_id,
thread_scope,
)
.await
{
PendingGateResolution::Resolved(p) => p,
PendingGateResolution::None => {
Expand Down Expand Up @@ -4576,6 +4586,49 @@ mod tests {
));
}

#[tokio::test]
async fn handle_approval_ignores_pending_gate_from_different_thread() {
let _guard = ENGINE_STATE_TEST_LOCK.lock().await;
let lock = ENGINE_STATE.get_or_init(|| RwLock::new(None));
*lock.write().await = None;

let outcome = async {
let store = Arc::new(TestStore::new());
let state = make_expected_test_state(store);
let pending_thread_id = ironclaw_engine::ThreadId::new();
let active_thread_id = ironclaw_engine::ThreadId::new();
let pending = sample_pending_gate(
"alice",
pending_thread_id,
ironclaw_engine::ResumeKind::Approval { allow_always: true },
);
state
.pending_gates
.insert(pending)
.await
.expect("insert pending gate");

*lock.write().await = Some(state);

let (agent, _statuses) = make_router_test_agent(None).await;
let message = IncomingMessage::new("gateway", "alice", "/approve")
.with_thread(active_thread_id.to_string());

let result = handle_approval(&agent, &message, true, false)
.await
.expect("handle approval");

assert_eq!(
result.as_deref(),
Some("No pending approval for this thread.")
);
}
.await;

*lock.write().await = None;
outcome
}

#[test]
fn resolved_call_id_prefers_stored_id_for_parallel_same_name_calls() {
let mut thread = ironclaw_engine::Thread::new(
Expand Down
Loading
Loading