use std::{future::Future, pin::Pin, sync::Mutex};
use agent_client_protocol::schema::v1::{
CreateElicitationResponse, ElicitationAcceptAction, ElicitationMode, RequestPermissionRequest,
RequestPermissionResponse, SessionNotification,
};
use pretty_assertions::assert_eq;
use serde_json::json;
use super::*;
#[derive(Clone, Copy, Debug)]
enum HostAnswer {
Accept,
Decline,
Dismiss,
Pending,
Refuses,
}
#[derive(Clone, Copy, Debug)]
enum Provider {
Approves,
Fails,
Waits,
}
struct FakeHost {
answer: HostAnswer,
shown: Mutex<Vec<CreateElicitationRequest>>,
completed: Mutex<Vec<ElicitationId>>,
}
impl AcpClientPort for FakeHost {
fn send_session_notification(
&self,
_notification: SessionNotification,
) -> Pin<Box<dyn Future<Output = Result<(), AcpError>> + Send + '_>> {
unreachable!("sign-in sends no session updates")
}
fn request_permission(
&self,
_request: RequestPermissionRequest,
) -> Pin<Box<dyn Future<Output = Result<RequestPermissionResponse, AcpError>> + Send + '_>>
{
unreachable!("sign-in asks no permission")
}
fn create_elicitation(
&self,
request: CreateElicitationRequest,
) -> Pin<Box<dyn Future<Output = Result<CreateElicitationResponse, AcpError>> + Send + '_>>
{
self.shown.lock().unwrap().push(request);
let action = match self.answer {
HostAnswer::Accept => ElicitationAction::Accept(ElicitationAcceptAction::new()),
HostAnswer::Decline => ElicitationAction::Decline,
HostAnswer::Dismiss => ElicitationAction::Cancel,
HostAnswer::Pending => return Box::pin(std::future::pending()),
HostAnswer::Refuses => return Box::pin(async { Err(AcpError::method_not_found()) }),
};
Box::pin(async move { Ok(CreateElicitationResponse::new(action)) })
}
fn complete_elicitation(
&self,
notification: CompleteElicitationNotification,
) -> Pin<Box<dyn Future<Output = Result<(), AcpError>> + Send + '_>> {
self.completed
.lock()
.unwrap()
.push(notification.elicitation_id);
Box::pin(async { Ok(()) })
}
}
#[tokio::test]
async fn sign_in_waits_for_the_provider_and_ends_with_the_host() {
let failed = || AcpError::internal_error().data("could not sign in to SpaceXAI: denied");
let cases = [
(HostAnswer::Accept, Provider::Approves, Ok(()), true),
(HostAnswer::Pending, Provider::Approves, Ok(()), true),
(HostAnswer::Accept, Provider::Fails, Err(failed()), false),
(
HostAnswer::Decline,
Provider::Waits,
Err(AcpError::request_cancelled()),
false,
),
(
HostAnswer::Dismiss,
Provider::Waits,
Err(AcpError::request_cancelled()),
false,
),
(
HostAnswer::Refuses,
Provider::Waits,
Err(AcpError::internal_error().data(format!(
"could not show the SpaceXAI sign-in link: {}; run `rho login xai-oauth` in a terminal instead",
AcpError::method_not_found()
))),
false,
),
];
let prompt = LoginPrompt::device_code(
"https://accounts.x.ai/device",
"ABCD-1234",
Some("https://accounts.x.ai/device?code=ABCD-1234".into()),
"Visit this URL and enter the code.",
);
for (answer, provider, expected, completes) in cases {
let host = FakeHost {
answer,
shown: Mutex::default(),
completed: Mutex::default(),
};
let signed_in = async move {
match provider {
Provider::Approves => Ok(()),
Provider::Fails => Err(failed()),
Provider::Waits => std::future::pending().await,
}
};
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
show_until_signed_in(
&host,
RequestId::Number(7),
ShownLogin {
auth: "xai-oauth",
provider_label: "SpaceXAI",
prompt: &prompt,
},
signed_in,
),
)
.await
.expect("sign-in ends without waiting on a login nobody finishes");
assert_eq!(result, expected, "{answer:?}, {provider:?}");
let shown = host.shown.into_inner().unwrap();
let [request] = shown.as_slice() else {
panic!("one elicitation per sign-in: {shown:?}");
};
let ElicitationMode::Url(mode) = &request.mode else {
panic!("sign-in is a URL elicitation: {request:?}");
};
assert_eq!(
serde_json::to_value(request).unwrap(),
json!({
"mode": "url",
"requestId": 7,
"elicitationId": mode.elicitation_id.0.as_ref(),
"url": "https://accounts.x.ai/device?code=ABCD-1234",
"message": request.message,
})
);
assert!(
request.message.contains("ABCD-1234"),
"the code to confirm is shown: {}",
request.message
);
let completed = host.completed.into_inner().unwrap();
let expected_completed = if completes {
vec![mode.elicitation_id.clone()]
} else {
Vec::new()
};
assert_eq!(completed, expected_completed, "{answer:?}, {provider:?}");
}
}
struct Releases(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for Releases {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
#[tokio::test]
async fn a_new_sign_in_replaces_abandoned_ones_after_they_let_go() {
use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc};
let flight = LoginFlight::default();
let released = Arc::new(AtomicBool::new(false));
let running = {
let guard = Releases(Arc::clone(&released));
async move {
let _port = guard;
std::future::pending::<Result<bool, AcpError>>().await
}
};
let queued = std::future::pending::<Result<bool, AcpError>>();
let newest = async { Ok(released.load(Ordering::SeqCst)) };
let outcomes = tokio::time::timeout(std::time::Duration::from_secs(10), async {
tokio::join!(flight.run(running), flight.run(queued), flight.run(newest))
})
.await
.expect("abandoned logins do not block the newest sign-in");
assert_eq!(
outcomes,
(
Err(AcpError::request_cancelled()),
Err(AcpError::request_cancelled()),
Ok(true),
),
"the newest sign-in ran after the running login was dropped"
);
}