Skip to content

Commit bdf9b7c

Browse files
committed
fix: route Claude AskUserQuestion answers via permission control response (#127)
1 parent c8fd3aa commit bdf9b7c

8 files changed

+252
-273
lines changed

server/packages/sandbox-agent/src/opencode_compat.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ use tokio::sync::{broadcast, Mutex};
2323
use tokio::time::interval;
2424
use utoipa::{IntoParams, OpenApi, ToSchema};
2525

26-
use crate::router::{AgentModelInfo, AppState, CreateSessionRequest, PermissionReply};
26+
use crate::router::{
27+
is_question_tool_action, AgentModelInfo, AppState, CreateSessionRequest, PermissionReply,
28+
};
2729
use sandbox_agent_agent_management::agents::AgentId;
2830
use sandbox_agent_error::SandboxError;
2931
use sandbox_agent_universal_agent_schema::{
@@ -1646,6 +1648,11 @@ async fn apply_permission_event(
16461648
event: UniversalEvent,
16471649
permission: PermissionEventData,
16481650
) {
1651+
// Suppress question-tool permissions (AskUserQuestion/ExitPlanMode) — these are
1652+
// handled internally via reply_question/reject_question, not exposed as permissions.
1653+
if is_question_tool_action(&permission.action) {
1654+
return;
1655+
}
16491656
let session_id = event.session_id.clone();
16501657
match permission.status {
16511658
PermissionStatus::Requested => {

server/packages/sandbox-agent/src/router.rs

Lines changed: 180 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,21 @@ impl SessionState {
690690

691691
self.update_pending(&event);
692692
self.update_item_tracking(&event);
693+
694+
// Suppress question-tool permissions (AskUserQuestion/ExitPlanMode) from frontends.
695+
// The permission is still stored in pending_permissions (via update_pending above)
696+
// so reply_question/reject_question can find and resolve it internally.
697+
if matches!(
698+
event.event_type,
699+
UniversalEventType::PermissionRequested | UniversalEventType::PermissionResolved
700+
) {
701+
if let UniversalEventData::Permission(ref data) = event.data {
702+
if is_question_tool_action(&data.action) {
703+
return None;
704+
}
705+
}
706+
}
707+
693708
self.events.push(event.clone());
694709
let _ = self.broadcaster.send(event.clone());
695710
if self.native_session_id.is_none() {
@@ -767,6 +782,17 @@ impl SessionState {
767782
self.pending_permissions.remove(permission_id)
768783
}
769784

785+
/// Find and remove a pending permission whose action matches a question tool
786+
/// (AskUserQuestion or ExitPlanMode variants). Returns (permission_id, PendingPermission).
787+
fn take_question_tool_permission(&mut self) -> Option<(String, PendingPermission)> {
788+
let key = self
789+
.pending_permissions
790+
.iter()
791+
.find(|(_, p)| is_question_tool_action(&p.action))
792+
.map(|(k, _)| k.clone());
793+
key.and_then(|k| self.pending_permissions.remove(&k).map(|p| (k, p)))
794+
}
795+
770796
fn mark_ended(
771797
&mut self,
772798
exit_code: Option<i32>,
@@ -2117,7 +2143,7 @@ impl SessionManager {
21172143
question_id: &str,
21182144
answers: Vec<Vec<String>>,
21192145
) -> Result<(), SandboxError> {
2120-
let (agent, native_session_id, pending_question, claude_sender) = {
2146+
let (agent, native_session_id, pending_question, claude_sender, linked_permission) = {
21212147
let mut sessions = self.sessions.lock().await;
21222148
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
21232149
SandboxError::SessionNotFound {
@@ -2133,11 +2159,18 @@ impl SessionManager {
21332159
if let Some(err) = session.ended_error() {
21342160
return Err(err);
21352161
}
2162+
// For Claude, check if there's a linked AskUserQuestion/ExitPlanMode permission
2163+
let linked_perm = if session.agent == AgentId::Claude {
2164+
session.take_question_tool_permission()
2165+
} else {
2166+
None
2167+
};
21362168
(
21372169
session.agent,
21382170
session.native_session_id.clone(),
21392171
pending,
21402172
session.claude_sender(),
2173+
linked_perm,
21412174
)
21422175
};
21432176

@@ -2150,28 +2183,67 @@ impl SessionManager {
21502183
.ok_or_else(|| SandboxError::InvalidRequest {
21512184
message: "missing OpenCode session id".to_string(),
21522185
})?;
2153-
self.opencode_question_reply(&agent_session_id, question_id, answers)
2186+
self.opencode_question_reply(&agent_session_id, question_id, answers.clone())
21542187
.await?;
21552188
} else if agent == AgentId::Claude {
21562189
let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest {
21572190
message: "Claude session is not active".to_string(),
21582191
})?;
2159-
let session_id = native_session_id
2160-
.clone()
2161-
.unwrap_or_else(|| session_id.to_string());
2162-
let response_text = response.clone().unwrap_or_default();
2163-
let line = claude_tool_result_line(&session_id, question_id, &response_text, false);
2164-
sender
2165-
.send(line)
2166-
.map_err(|_| SandboxError::InvalidRequest {
2167-
message: "Claude session is not active".to_string(),
2168-
})?;
2192+
if let Some((perm_id, perm)) = &linked_permission {
2193+
// Use the permission control response to deliver the answer.
2194+
// Build updatedInput from the original input with the answers map added.
2195+
let original_input = perm
2196+
.metadata
2197+
.as_ref()
2198+
.and_then(|m| m.get("input"))
2199+
.cloned()
2200+
.unwrap_or(Value::Null);
2201+
let mut updated = match original_input {
2202+
Value::Object(map) => map,
2203+
_ => serde_json::Map::new(),
2204+
};
2205+
// Build answers map: { "0": "selected option", "1": "another option", ... }
2206+
let answers_map: serde_json::Map<String, Value> = answers
2207+
.iter()
2208+
.enumerate()
2209+
.filter_map(|(i, inner)| {
2210+
inner
2211+
.first()
2212+
.map(|v| (i.to_string(), Value::String(v.clone())))
2213+
})
2214+
.collect();
2215+
updated.insert("answers".to_string(), Value::Object(answers_map));
2216+
2217+
let mut response_map = serde_json::Map::new();
2218+
response_map.insert("updatedInput".to_string(), Value::Object(updated));
2219+
let line =
2220+
claude_control_response_line(perm_id, "allow", Value::Object(response_map));
2221+
sender
2222+
.send(line)
2223+
.map_err(|_| SandboxError::InvalidRequest {
2224+
message: "Claude session is not active".to_string(),
2225+
})?;
2226+
} else {
2227+
// No linked permission — fall back to tool_result
2228+
let native_sid = native_session_id
2229+
.clone()
2230+
.unwrap_or_else(|| session_id.to_string());
2231+
let response_text = response.clone().unwrap_or_default();
2232+
let line =
2233+
claude_tool_result_line(&native_sid, question_id, &response_text, false);
2234+
sender
2235+
.send(line)
2236+
.map_err(|_| SandboxError::InvalidRequest {
2237+
message: "Claude session is not active".to_string(),
2238+
})?;
2239+
}
21692240
} else {
21702241
// TODO: Forward question replies to subprocess agents.
21712242
}
21722243

2244+
// Emit QuestionResolved
21732245
if let Some(pending) = pending_question {
2174-
let resolved = EventConversion::new(
2246+
let mut conversions = vec![EventConversion::new(
21752247
UniversalEventType::QuestionResolved,
21762248
UniversalEventData::Question(QuestionEventData {
21772249
question_id: question_id.to_string(),
@@ -2182,8 +2254,26 @@ impl SessionManager {
21822254
}),
21832255
)
21842256
.synthetic()
2185-
.with_native_session(native_session_id);
2186-
let _ = self.record_conversions(session_id, vec![resolved]).await;
2257+
.with_native_session(native_session_id.clone())];
2258+
2259+
// Also emit PermissionResolved for the linked permission
2260+
if let Some((perm_id, perm)) = linked_permission {
2261+
conversions.push(
2262+
EventConversion::new(
2263+
UniversalEventType::PermissionResolved,
2264+
UniversalEventData::Permission(PermissionEventData {
2265+
permission_id: perm_id,
2266+
action: perm.action,
2267+
status: PermissionStatus::Approved,
2268+
metadata: perm.metadata,
2269+
}),
2270+
)
2271+
.synthetic()
2272+
.with_native_session(native_session_id),
2273+
);
2274+
}
2275+
2276+
let _ = self.record_conversions(session_id, conversions).await;
21872277
}
21882278

21892279
Ok(())
@@ -2194,7 +2284,7 @@ impl SessionManager {
21942284
session_id: &str,
21952285
question_id: &str,
21962286
) -> Result<(), SandboxError> {
2197-
let (agent, native_session_id, pending_question, claude_sender) = {
2287+
let (agent, native_session_id, pending_question, claude_sender, linked_permission) = {
21982288
let mut sessions = self.sessions.lock().await;
21992289
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
22002290
SandboxError::SessionNotFound {
@@ -2210,11 +2300,17 @@ impl SessionManager {
22102300
if let Some(err) = session.ended_error() {
22112301
return Err(err);
22122302
}
2303+
let linked_perm = if session.agent == AgentId::Claude {
2304+
session.take_question_tool_permission()
2305+
} else {
2306+
None
2307+
};
22132308
(
22142309
session.agent,
22152310
session.native_session_id.clone(),
22162311
pending,
22172312
session.claude_sender(),
2313+
linked_perm,
22182314
)
22192315
};
22202316

@@ -2231,26 +2327,43 @@ impl SessionManager {
22312327
let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest {
22322328
message: "Claude session is not active".to_string(),
22332329
})?;
2234-
let session_id = native_session_id
2235-
.clone()
2236-
.unwrap_or_else(|| session_id.to_string());
2237-
let line = claude_tool_result_line(
2238-
&session_id,
2239-
question_id,
2240-
"User rejected the question.",
2241-
true,
2242-
);
2243-
sender
2244-
.send(line)
2245-
.map_err(|_| SandboxError::InvalidRequest {
2246-
message: "Claude session is not active".to_string(),
2247-
})?;
2330+
if let Some((perm_id, _)) = &linked_permission {
2331+
// Deny via the permission control response
2332+
let mut response_map = serde_json::Map::new();
2333+
response_map.insert(
2334+
"message".to_string(),
2335+
Value::String("Permission denied.".to_string()),
2336+
);
2337+
let line =
2338+
claude_control_response_line(perm_id, "deny", Value::Object(response_map));
2339+
sender
2340+
.send(line)
2341+
.map_err(|_| SandboxError::InvalidRequest {
2342+
message: "Claude session is not active".to_string(),
2343+
})?;
2344+
} else {
2345+
let native_sid = native_session_id
2346+
.clone()
2347+
.unwrap_or_else(|| session_id.to_string());
2348+
let line = claude_tool_result_line(
2349+
&native_sid,
2350+
question_id,
2351+
"User rejected the question.",
2352+
true,
2353+
);
2354+
sender
2355+
.send(line)
2356+
.map_err(|_| SandboxError::InvalidRequest {
2357+
message: "Claude session is not active".to_string(),
2358+
})?;
2359+
}
22482360
} else {
22492361
// TODO: Forward question rejections to subprocess agents.
22502362
}
22512363

2364+
// Emit QuestionResolved
22522365
if let Some(pending) = pending_question {
2253-
let resolved = EventConversion::new(
2366+
let mut conversions = vec![EventConversion::new(
22542367
UniversalEventType::QuestionResolved,
22552368
UniversalEventData::Question(QuestionEventData {
22562369
question_id: question_id.to_string(),
@@ -2261,8 +2374,26 @@ impl SessionManager {
22612374
}),
22622375
)
22632376
.synthetic()
2264-
.with_native_session(native_session_id);
2265-
let _ = self.record_conversions(session_id, vec![resolved]).await;
2377+
.with_native_session(native_session_id.clone())];
2378+
2379+
// Also emit PermissionResolved for the linked permission
2380+
if let Some((perm_id, perm)) = linked_permission {
2381+
conversions.push(
2382+
EventConversion::new(
2383+
UniversalEventType::PermissionResolved,
2384+
UniversalEventData::Permission(PermissionEventData {
2385+
permission_id: perm_id,
2386+
action: perm.action,
2387+
status: PermissionStatus::Denied,
2388+
metadata: perm.metadata,
2389+
}),
2390+
)
2391+
.synthetic()
2392+
.with_native_session(native_session_id),
2393+
);
2394+
}
2395+
2396+
let _ = self.record_conversions(session_id, conversions).await;
22662397
}
22672398

22682399
Ok(())
@@ -5076,6 +5207,22 @@ fn claude_control_response_line(request_id: &str, behavior: &str, response: Valu
50765207
.to_string()
50775208
}
50785209

5210+
/// Returns true if the given action name corresponds to a question tool
5211+
/// (AskUserQuestion or ExitPlanMode in any casing convention).
5212+
pub(crate) fn is_question_tool_action(action: &str) -> bool {
5213+
matches!(
5214+
action,
5215+
"AskUserQuestion"
5216+
| "ask_user_question"
5217+
| "askUserQuestion"
5218+
| "ask-user-question"
5219+
| "ExitPlanMode"
5220+
| "exit_plan_mode"
5221+
| "exitPlanMode"
5222+
| "exit-plan-mode"
5223+
)
5224+
}
5225+
50795226
fn read_lines<R: std::io::Read>(reader: R, sender: mpsc::UnboundedSender<String>) {
50805227
let mut reader = BufReader::new(reader);
50815228
let mut line = String::new();

0 commit comments

Comments
 (0)