Skip to content

Commit 6dd07b9

Browse files
tclemCopilot
andcommitted
Fix duplicate user_input dispatch (github-app#4249)
The CLI sends two messages for every user-input prompt: 1. A `user_input.requested` session-event notification, intended for observers (UI streaming, telemetry, logging). 2. A `userInput.request` JSON-RPC method call, which is the actual ask-and-wait prompt that drives the handler. The Rust SDK's session event loop was wiring up `HandlerEvent::UserInput` dispatch on BOTH paths. Result: the consumer's `on_event` ran twice for every prompt, which surfaced as duplicate `ask_user` / `exit_plan` widgets in github-app's session UI (#4249). Other SDKs (Node, Python, Go, .NET) only register the JSON-RPC handler; the notification is purely observational. Aligning with that convention. Changes: 1. `session.rs`: drop the `SessionEventType::UserInputRequested` auto-dispatch arm. The CLI's follow-up `userInput.request` JSON-RPC call still drives the handler. Replaced with a short comment block explaining why the notification is intentionally a no-op (and citing #4249 so the next person tempted to add it back understands the trade). 2. `tests/session_test.rs`: replaced the `user_input_requested_event_dispatches_to_handler_and_responds` test (which asserted the broken behavior) with a regression test (`user_input_requested_notification_does_not_double_dispatch`) that: - sends a `user_input.requested` notification and asserts no wire response, no handler invocation; - then sends a `userInput.request` JSON-RPC call and asserts the handler fires exactly once. This locks the fix in place and makes a future re-introduction surface immediately as a test failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 393d158 commit 6dd07b9

3 files changed

Lines changed: 70 additions & 68 deletions

File tree

rust/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,14 @@ public surface.
416416
fail fast as before.
417417

418418
### Fixed
419+
- `Session::user_input` no longer double-dispatches when the CLI sends
420+
both a `user_input.requested` notification (for observers) and a
421+
`userInput.request` JSON-RPC call (the actual prompt) for the same
422+
prompt. The notification path is now a no-op; the JSON-RPC path
423+
remains authoritative. Matches Python / Go / .NET / Node SDK
424+
behavior, all of which only register the JSON-RPC handler. Fixes
425+
github/github-app#4249, where consumers saw duplicate `ask_user`
426+
and `exit_plan` widgets on every prompt.
419427
- `SessionUi::elicitation` (and the `confirm` / `select` / `input`
420428
convenience helpers that delegate through it) now sends the user-supplied
421429
JSON Schema as `requestedSchema` on the wire, matching the

rust/src/session.rs

Lines changed: 7 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use crate::generated::api_types::{
1717
};
1818
use crate::generated::session_events::{
1919
CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, SessionErrorData,
20-
SessionEventType, UserInputRequestedData,
20+
SessionEventType,
2121
};
2222
use crate::handler::{
2323
AutoModeSwitchResponse, ExitPlanModeResult, HandlerEvent, HandlerResponse, PermissionResult,
@@ -1306,47 +1306,12 @@ async fn handle_notification(
13061306
});
13071307
}
13081308
SessionEventType::UserInputRequested => {
1309-
let user_input_data: UserInputRequestedData =
1310-
match serde_json::from_value(notification.event.data.clone()) {
1311-
Ok(d) => d,
1312-
Err(e) => {
1313-
warn!(error = %e, "failed to deserialize user_input.requested");
1314-
return;
1315-
}
1316-
};
1317-
let client = client.clone();
1318-
let handler = handler.clone();
1319-
let sid = session_id.clone();
1320-
tokio::spawn(async move {
1321-
let response = handler
1322-
.on_event(HandlerEvent::UserInput {
1323-
session_id: sid.clone(),
1324-
question: user_input_data.question,
1325-
choices: (!user_input_data.choices.is_empty())
1326-
.then_some(user_input_data.choices),
1327-
allow_freeform: user_input_data.allow_freeform,
1328-
})
1329-
.await;
1330-
let result = match response {
1331-
HandlerResponse::UserInput(Some(UserInputResponse {
1332-
answer,
1333-
was_freeform,
1334-
})) => serde_json::json!({
1335-
"sessionId": sid,
1336-
"requestId": user_input_data.request_id,
1337-
"answer": answer,
1338-
"wasFreeform": was_freeform,
1339-
}),
1340-
_ => serde_json::json!({
1341-
"sessionId": sid,
1342-
"requestId": user_input_data.request_id,
1343-
"noResponse": true,
1344-
}),
1345-
};
1346-
let _ = client
1347-
.call("session.respondToUserInput", Some(result))
1348-
.await;
1349-
});
1309+
// Notification-only signal for observers (UI, telemetry).
1310+
// The CLI follows up with a `userInput.request` JSON-RPC call
1311+
// that drives `HandlerEvent::UserInput` dispatch — handling
1312+
// the notification here too would double-fire the handler
1313+
// and produce duplicate prompts on the consumer side. See
1314+
// github/github-app#4249.
13501315
}
13511316
SessionEventType::ElicitationRequested => {
13521317
let Some(request_id) = extract_request_id(&notification.event.data) else {

rust/tests/session_test.rs

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,32 +1386,38 @@ async fn user_input_request_dispatches_to_handler() {
13861386
}
13871387

13881388
#[tokio::test]
1389-
async fn user_input_requested_event_dispatches_to_handler_and_responds() {
1390-
struct InputHandler;
1389+
async fn user_input_requested_notification_does_not_double_dispatch() {
1390+
use std::sync::atomic::{AtomicUsize, Ordering};
1391+
// Regression for github/github-app#4249. The CLI sends BOTH a
1392+
// `user_input.requested` notification (for observers) AND a
1393+
// `userInput.request` JSON-RPC call (the actual prompt) for every
1394+
// user-input prompt. Only the JSON-RPC path should reach the
1395+
// handler — dispatching from the notification too produced
1396+
// duplicate ask_user widgets on the consumer side.
1397+
1398+
struct CountingHandler {
1399+
invocations: Arc<AtomicUsize>,
1400+
}
13911401
#[async_trait]
1392-
impl SessionHandler for InputHandler {
1402+
impl SessionHandler for CountingHandler {
13931403
async fn on_event(&self, event: HandlerEvent) -> HandlerResponse {
1394-
match event {
1395-
HandlerEvent::UserInput {
1396-
question,
1397-
choices,
1398-
allow_freeform,
1399-
..
1400-
} => {
1401-
assert_eq!(question, "Allow shell access?");
1402-
assert_eq!(choices, Some(vec!["Yes".to_string(), "No".to_string()]));
1403-
assert_eq!(allow_freeform, Some(false));
1404-
HandlerResponse::UserInput(Some(UserInputResponse {
1405-
answer: "Yes".to_string(),
1406-
was_freeform: false,
1407-
}))
1408-
}
1409-
_ => HandlerResponse::Ok,
1404+
if let HandlerEvent::UserInput { .. } = event {
1405+
self.invocations.fetch_add(1, Ordering::SeqCst);
1406+
return HandlerResponse::UserInput(Some(UserInputResponse {
1407+
answer: "ok".to_string(),
1408+
was_freeform: true,
1409+
}));
14101410
}
1411+
HandlerResponse::Ok
14111412
}
14121413
}
14131414

1414-
let (_session, mut server) = create_session_pair(Arc::new(InputHandler)).await;
1415+
let invocations = Arc::new(AtomicUsize::new(0));
1416+
let handler = Arc::new(CountingHandler {
1417+
invocations: invocations.clone(),
1418+
});
1419+
let (_session, mut server) = create_session_pair(handler).await;
1420+
14151421
server
14161422
.send_event(
14171423
"user_input.requested",
@@ -1424,12 +1430,35 @@ async fn user_input_requested_event_dispatches_to_handler_and_responds() {
14241430
)
14251431
.await;
14261432

1427-
let request = timeout(TIMEOUT, server.read_request()).await.unwrap();
1428-
assert_eq!(request["method"], "session.respondToUserInput");
1429-
assert_eq!(request["params"]["sessionId"], server.session_id);
1430-
assert_eq!(request["params"]["requestId"], "ui-1");
1431-
assert_eq!(request["params"]["answer"], "Yes");
1432-
assert_eq!(request["params"]["wasFreeform"], false);
1433+
// Give the SDK a beat to (incorrectly) auto-dispatch if the
1434+
// regression returned. Nothing should arrive on the wire.
1435+
let respond_observed = timeout(Duration::from_millis(150), server.read_request()).await;
1436+
assert!(
1437+
respond_observed.is_err(),
1438+
"notification triggered unexpected wire activity: {respond_observed:?}",
1439+
);
1440+
assert_eq!(
1441+
invocations.load(Ordering::SeqCst),
1442+
0,
1443+
"notification path must not invoke the user-input handler",
1444+
);
1445+
1446+
// Now drive the JSON-RPC path and confirm the handler still runs once.
1447+
server
1448+
.send_request(
1449+
301,
1450+
"userInput.request",
1451+
serde_json::json!({
1452+
"sessionId": server.session_id,
1453+
"question": "Pick a color",
1454+
"allowFreeform": true,
1455+
}),
1456+
)
1457+
.await;
1458+
let response = timeout(TIMEOUT, server.read_response()).await.unwrap();
1459+
assert_eq!(response["id"], 301);
1460+
assert_eq!(response["result"]["answer"], "ok");
1461+
assert_eq!(invocations.load(Ordering::SeqCst), 1);
14331462
}
14341463

14351464
#[tokio::test]

0 commit comments

Comments
 (0)