1use crate::commands::api::{CallOptions, RequestPlan, call_method, plan_request};
38use crate::commands::safety::SafetyProfile;
39use crate::core::auth::GoogleAuth;
40use crate::core::config::GrrConfig;
41use crate::discovery::{self, Method, Parameter, Service};
42use crate::output::{OutputFormat, print_output};
43use anyhow::{Context, Result, bail};
44use clap::Args;
45use serde_json::{Value, json};
46
47const NONE: &str = "none";
49
50const MAX_CHOICE_OPTIONS: usize = 255;
53
54#[derive(Args, Debug)]
55pub struct AskArgs {
56 pub request: String,
58
59 #[arg(long)]
62 pub run: bool,
63
64 #[arg(long, value_name = "SERVICE")]
68 pub service: Option<String>,
69
70 #[arg(long, value_name = "ID")]
73 pub method: Option<String>,
74
75 #[arg(long, conflicts_with = "run")]
78 pub dry_run: bool,
79
80 #[arg(short, long, value_enum, default_value = "json")]
82 pub format: OutputFormat,
83}
84
85struct SystemOne {
91 client: reqwest::Client,
92 endpoint: String,
93 model: String,
94 api_key: String,
95}
96
97fn resolve_systemone(config: &GrrConfig) -> Result<SystemOne> {
98 let s1 = &config.systemone;
99 let api_key = s1
100 .bearer_key()
101 .ok_or_else(|| anyhow::anyhow!(crate::core::config::NO_API_KEY_HELP))?;
102 Ok(SystemOne {
103 client: reqwest::Client::builder()
104 .timeout(std::time::Duration::from_secs(30))
105 .build()
106 .map_err(|e| anyhow::anyhow!("failed to build the System One HTTP client: {e}"))?,
107 endpoint: s1.endpoint_or_default().to_owned(),
108 model: s1.model_or_default().to_owned(),
109 api_key: api_key.to_owned(),
110 })
111}
112
113impl SystemOne {
114 fn masked_endpoint(&self) -> String {
117 match self.endpoint.split_once('?') {
118 Some((before, _)) => format!("{before}?…"),
119 None => self.endpoint.clone(),
120 }
121 }
122}
123
124async fn judge(s1: &SystemOne, state: Value, questions: Value) -> Result<Value> {
128 let body = json!({
129 "state": state,
130 "model": s1.model,
131 "questions": questions,
132 });
133 let response = s1
134 .client
135 .post(&s1.endpoint)
136 .bearer_auth(&s1.api_key)
137 .json(&body)
138 .send()
139 .await
140 .map_err(|e| {
141 anyhow::anyhow!("System One request to {} failed: {e}", s1.masked_endpoint())
142 })?;
143
144 let status = response.status();
145 let text = response.text().await.unwrap_or_default();
146 if !status.is_success() {
147 if status.as_u16() == 429 {
148 bail!(
149 "System One endpoint {} is rate limited (HTTP 429); retry shortly",
150 s1.masked_endpoint()
151 );
152 }
153 bail!(
154 "System One endpoint {} returned HTTP {}.\n{}",
155 s1.masked_endpoint(),
156 status.as_u16(),
157 text
158 );
159 }
160
161 let parsed: Value = serde_json::from_str(&text).with_context(|| {
162 format!(
163 "System One response from {} is not JSON",
164 s1.masked_endpoint()
165 )
166 })?;
167 parsed
168 .get("answers")
169 .cloned()
170 .ok_or_else(|| anyhow::anyhow!("System One response has no `answers` object:\n{text}"))
171}
172
173fn choice_answer<'a>(answers: &'a Value, id: &str) -> Result<(&'a str, f64)> {
179 let answer = answers
180 .get(id)
181 .ok_or_else(|| anyhow::anyhow!("System One returned no answer for `{id}`"))?;
182 if let Some(kind) = answer.get("type").and_then(Value::as_str)
183 && kind != "choice"
184 {
185 bail!(
186 "System One answered question `{id}` with type `{kind}`, not `choice` — \
187 the provider must speak the System One contract"
188 );
189 }
190 let choice = answer
191 .get("choice")
192 .and_then(Value::as_str)
193 .ok_or_else(|| {
194 anyhow::anyhow!("System One's answer for `{id}` has no `choice` value:\n{answer}")
195 })?;
196 let confidence = answer
197 .get("confidence")
198 .and_then(Value::as_f64)
199 .unwrap_or(0.0);
200 Ok((choice, confidence))
201}
202
203fn resource_segments(service: &Service) -> Vec<String> {
207 let mut out: Vec<String> = Vec::new();
208 for method in &service.methods {
209 let Some(cut) = method.id.rfind('.') else {
210 continue;
211 };
212 for segment in method.id[..cut].split('.') {
213 if !out.iter().any(|s| s == segment) {
214 out.push(segment.to_owned());
215 }
216 }
217 }
218 out.truncate(14);
219 out
220}
221
222fn service_rubric(service: &Service) -> String {
227 let title = if service.title.is_empty() {
228 &service.name
229 } else {
230 &service.title
231 };
232 format!(
233 "{} ({}): {} methods over {}",
234 title,
235 service.version,
236 service.methods.len(),
237 resource_segments(service).join(", ")
238 )
239}
240
241fn service_by_name(name: &str) -> Result<&'static Service> {
242 discovery::services().get(name).ok_or_else(|| {
243 let known: Vec<&str> = discovery::services().keys().copied().collect();
244 anyhow::anyhow!("unknown service `{name}`; known: {}", known.join(", "))
245 })
246}
247
248async fn choose_service(s1: &SystemOne, request: &str) -> Result<(&'static Service, f64)> {
250 if discovery::services().len() >= MAX_CHOICE_OPTIONS {
253 bail!(
254 "{} services exceed the {MAX_CHOICE_OPTIONS}-option Choice limit",
255 discovery::services().len()
256 );
257 }
258
259 let mut criteria = serde_json::Map::new();
260 for (name, service) in discovery::services() {
261 criteria.insert((*name).to_owned(), json!(service_rubric(service)));
262 }
263 criteria.insert(
264 NONE.to_owned(),
265 json!("None of these services covers what the request needs."),
266 );
267
268 let catalog: Vec<Value> = discovery::services()
269 .values()
270 .map(|service| {
271 json!({
272 "name": service.name,
273 "methods": service.methods.len(),
274 "resources": resource_segments(service),
275 })
276 })
277 .collect();
278
279 let state = json!({
280 "request": request,
281 "catalog": catalog,
282 });
283 let questions = json!({
284 "service": {
285 "type": "choice",
286 "instructions": {
287 "question": "Which service should handle this request?",
288 "focus": "Pick the one service whose methods cover what the request needs. Choose `none` when no service does.",
289 },
290 "criteria": criteria,
291 },
292 });
293
294 let answers = judge(s1, state, questions).await?;
295 let (choice, confidence) = choice_answer(&answers, "service")?;
296 if choice == NONE {
297 bail!(
298 "none of the indexed services covers `{request}`.\n\
299 Run `grr api list` to see what is available, or rephrase the request in terms of one of them."
300 );
301 }
302 let service = discovery::services().get(choice).ok_or_else(|| {
303 anyhow::anyhow!("System One chose `{choice}`, which is not an indexed service")
304 })?;
305 Ok((service, confidence))
306}
307
308async fn choose_method<'a>(
312 s1: &SystemOne,
313 request: &str,
314 service: &'a Service,
315) -> Result<(&'a Method, f64)> {
316 if service.methods.len() >= MAX_CHOICE_OPTIONS {
320 bail!(
321 "{} has {} methods, exceeding the {MAX_CHOICE_OPTIONS}-option Choice limit",
322 service.name,
323 service.methods.len()
324 );
325 }
326
327 let mut criteria = serde_json::Map::new();
328 for method in &service.methods {
329 criteria.insert(
330 method.id.clone(),
331 json!(method_summary(&method.description)),
332 );
333 }
334 criteria.insert(
335 NONE.to_owned(),
336 json!("None of these methods does what the request needs."),
337 );
338
339 let state = json!({
340 "request": request,
341 "service": {
342 "name": service.name,
343 "title": service.title,
344 "version": service.version,
345 "methods": service.methods.len(),
346 },
347 "step": "The service was already chosen. Now pick the one method that does what the request needs. Choose `none` when none does.",
348 });
349 let questions = json!({
350 "method": {
351 "type": "choice",
352 "instructions": {
353 "question": format!(
354 "Which method of the {} service should handle this request?",
355 service.name
356 ),
357 "focus": "Match the request's intent to one method's description.",
358 },
359 "criteria": criteria,
360 },
361 });
362
363 let answers = judge(s1, state, questions).await?;
364 let (choice, confidence) = choice_answer(&answers, "method")?;
365 if choice == NONE {
366 bail!(
367 "none of {}'s {} methods covers `{request}`.\n\
368 Run `grr api list {}` to see what is available, or rephrase the request.",
369 service.name,
370 service.methods.len(),
371 service.name
372 );
373 }
374 let method = service.method(choice).ok_or_else(|| {
375 anyhow::anyhow!(
376 "System One chose `{choice}`, which is not a method of `{}`",
377 service.name
378 )
379 })?;
380 Ok((method, confidence))
381}
382
383fn method_summary(description: &str) -> String {
384 description.chars().take(200).collect()
385}
386
387fn is_user_param(name: &str) -> bool {
390 matches!(
391 name.to_ascii_lowercase().as_str(),
392 "userid" | "user" | "userkey"
393 )
394}
395
396fn value_candidates(request: &str) -> Vec<String> {
402 let mut out: Vec<String> = Vec::new();
403 let mut rest = request;
404 while let Some(start) = rest.find(['\'', '"']) {
405 let quote = &rest[start..start + 1];
406 let Some(end) = rest[start + 1..].find(quote) else {
407 break;
408 };
409 push_candidate(&mut out, &rest[start + 1..start + 1 + end]);
410 rest = &rest[start + 1 + end + 1..];
411 }
412 for token in request.split_whitespace() {
413 let cleaned = token.trim_matches(|c: char| ".,;:!?()[]{}\"'".contains(c));
414 if cleaned.chars().any(|c| c.is_ascii_digit() || c == '@') {
415 push_candidate(&mut out, cleaned);
416 }
417 }
418 out.truncate(8);
419 out
420}
421
422fn push_candidate(out: &mut Vec<String>, value: &str) {
423 let value = value.trim();
424 if value.is_empty()
425 || value.len() > 120
426 || value == NONE
427 || value == "me"
428 || out.iter().any(|c| c == value)
429 {
430 return;
431 }
432 out.push(value.to_owned());
433}
434
435fn param_question(param: &Parameter, method_description: &str, candidates: &[String]) -> Value {
442 let mut criteria = serde_json::Map::new();
443 match ¶m.enum_values {
444 Some(values) => {
445 for value in values {
446 criteria.insert(value.clone(), Value::Null);
447 }
448 }
449 None => {
450 for candidate in candidates {
451 criteria.insert(candidate.clone(), Value::Null);
452 }
453 if is_user_param(¶m.name) {
454 criteria.insert(
455 "me".to_owned(),
456 json!("The authenticated user — the universal alias for this parameter"),
457 );
458 }
459 }
460 }
461 criteria.insert(
462 NONE.to_owned(),
463 json!(format!(
464 "No value in the request fits the `{}` parameter.",
465 param.name
466 )),
467 );
468
469 json!({
470 "type": "choice",
471 "instructions": {
472 "question": format!("What value should the `{}` parameter take?", param.name),
473 "method": method_description,
474 "parameter": {
475 "name": param.name,
476 "kind": param.kind,
477 "description": param.description,
478 "required": true,
479 },
480 },
481 "criteria": criteria,
482 })
483}
484
485fn param_fill_questions(
489 method_description: &str,
490 method: &Method,
491 candidates: &[String],
492) -> serde_json::Map<String, Value> {
493 let mut questions = serde_json::Map::new();
494 for required in &method.required {
495 let Some(param) = method.parameter(required) else {
496 continue;
497 };
498 questions.insert(
499 param.name.clone(),
500 param_question(param, method_description, candidates),
501 );
502 }
503 questions
504}
505
506fn coerce_value(choice: &str, param: &Parameter) -> Value {
510 match param.kind.as_str() {
511 "integer" => choice
512 .parse::<i64>()
513 .map(|n| json!(n))
514 .unwrap_or(json!(choice)),
515 "number" => choice
516 .parse::<f64>()
517 .map(|n| json!(n))
518 .unwrap_or(json!(choice)),
519 "boolean" => choice
520 .parse::<bool>()
521 .map(|b| json!(b))
522 .unwrap_or(json!(choice)),
523 _ => json!(choice),
524 }
525}
526
527async fn fill_params(
538 s1: &SystemOne,
539 request: &str,
540 service: &Service,
541 method: &Method,
542) -> Result<serde_json::Map<String, Value>> {
543 let candidates = value_candidates(request);
544 let questions = param_fill_questions(&method.description, method, &candidates);
545 if questions.is_empty() {
546 return Ok(serde_json::Map::new());
547 }
548
549 let state = json!({
550 "request": request,
551 "method": {
552 "id": format!("{}.{}", service.name, method.id),
553 "description": method.description,
554 "http": method.http_method,
555 },
556 "step": "The method was already chosen. Now fill each required parameter from the request.",
557 });
558
559 let answers = judge(s1, state, Value::Object(questions)).await?;
560
561 let mut params = serde_json::Map::new();
562 for required in &method.required {
563 let Some(param) = method.parameter(required) else {
564 continue;
565 };
566 let (choice, _) = choice_answer(&answers, ¶m.name)?;
567 if choice == NONE {
568 let full_id = format!("{}.{}", service.name, method.id);
569 bail!(
570 "could not fill the required parameter `{}` for {full_id} from the request.\n\
571 Run `grr api describe {full_id}` to see its parameters, or supply them directly:\n\
572 `grr api call {full_id} --params '{{\"{}\": \"...\"}}'`",
573 param.name,
574 param.name
575 );
576 }
577 params.insert(param.name.clone(), coerce_value(choice, param));
578 }
579 Ok(params)
580}
581
582#[derive(Debug)]
587struct AskResolution {
588 service: &'static Service,
589 method: &'static Method,
590 params: serde_json::Map<String, Value>,
591 confidence: Option<f64>,
592 service_confidence: Option<f64>,
593 model: String,
594 provider: String,
595 plan: RequestPlan,
596 low_confidence: bool,
597}
598
599async fn resolve_ask(
604 config: &GrrConfig,
605 args: &AskArgs,
606 profile: &SafetyProfile,
607) -> Result<AskResolution> {
608 let s1 = resolve_systemone(config)?;
609 let request = args.request.trim();
610
611 let (service, method, method_confidence, service_confidence) =
612 if let Some(id) = args.method.as_deref() {
613 if let Some(wanted) = args.service.as_deref() {
614 let actual = id.split('.').next().unwrap_or(id);
615 if !actual.eq_ignore_ascii_case(wanted) {
616 bail!(
617 "--method {id} belongs to service `{actual}`, but --service {wanted} \
618 was given. Drop --service or use a matching method id."
619 );
620 }
621 }
622 let (service, method) = discovery::resolve(id).map_err(|message| {
623 anyhow::anyhow!(
624 "{message}\n\nRun `grr api list {}` to see what is available.",
625 id.split('.').next().unwrap_or("gmail")
626 )
627 })?;
628 (service, method, None, None)
629 } else if let Some(name) = args.service.as_deref() {
630 let service = service_by_name(name)?;
631 let (method, confidence) = choose_method(&s1, request, service).await?;
632 (service, method, Some(confidence), None)
633 } else {
634 let (service, s_confidence) = choose_service(&s1, request).await?;
635 let (method, m_confidence) = choose_method(&s1, request, service).await?;
636 (service, method, Some(m_confidence), Some(s_confidence))
637 };
638
639 if let Err(message) = profile.check(&service.name, method) {
643 bail!("{message}");
644 }
645
646 let params = if method.required.is_empty() {
647 serde_json::Map::new()
648 } else {
649 fill_params(&s1, request, service, method).await?
650 };
651
652 let plan = plan_request(service, method, ¶ms, None, None, &[])?;
653
654 let threshold = config.systemone.threshold();
655 let low_confidence = method_confidence.is_some_and(|c| c < threshold)
656 || service_confidence.is_some_and(|c| c < threshold);
657
658 Ok(AskResolution {
659 service,
660 method,
661 params,
662 confidence: method_confidence,
663 service_confidence,
664 model: s1.model,
665 provider: s1.endpoint,
666 plan,
667 low_confidence,
668 })
669}
670
671fn ask_plan_payload(request: &str, resolution: &AskResolution) -> Value {
674 json!({
675 "request": request,
676 "method": format!("{}.{}", resolution.service.name, resolution.method.id),
677 "service": resolution.service.name,
678 "httpMethod": resolution.plan.verb,
679 "params": resolution.params,
680 "url": resolution.plan.url,
681 "body": resolution.plan.body,
682 "scopes": resolution.method.scopes,
683 "leastPrivilegeScope": resolution.method.least_privilege_scope(),
684 "confidence": resolution.confidence,
685 "serviceConfidence": resolution.service_confidence,
686 "model": resolution.model,
687 "provider": resolution.provider,
688 "dryRun": true,
689 "lowConfidence": resolution.low_confidence,
690 "nextStep": "rerun with --run to execute this request",
691 })
692}
693
694pub async fn handle_ask(
702 config: &GrrConfig,
703 auth: Option<&GoogleAuth>,
704 args: AskArgs,
705 profile: &SafetyProfile,
706) -> Result<()> {
707 let resolution = resolve_ask(config, &args, profile).await?;
708 let format = args.format;
709 let full_id = format!("{}.{}", resolution.service.name, resolution.method.id);
710
711 if resolution.low_confidence {
712 let confidence = resolution
713 .confidence
714 .or(resolution.service_confidence)
715 .unwrap_or(0.0);
716 eprintln!(
717 "warning: low confidence ({confidence:.2} < {:.2}); verify the resolved method before trusting it",
718 config.systemone.threshold()
719 );
720 }
721
722 if args.dry_run || !args.run {
723 eprintln!(
724 "note: resolved {full_id} ({} {}); plan below — rerun with --run to execute",
725 resolution.plan.verb, resolution.plan.url
726 );
727 print_output(&ask_plan_payload(&args.request, &resolution), format)?;
728 return Ok(());
729 }
730
731 let Some(auth) = auth else {
732 bail!(
733 "--run needs a Google credential, but none was built. Re-run without --run to print the plan only."
734 );
735 };
736 eprintln!(
737 "note: resolved {full_id} ({} {}); running",
738 resolution.plan.verb, resolution.plan.url
739 );
740 let payload = call_method(
741 Some(auth),
742 resolution.service,
743 resolution.method,
744 resolution.params,
745 CallOptions {
746 dry_run: false,
747 ..Default::default()
748 },
749 )
750 .await?;
751 print_output(&payload, format)?;
752 Ok(())
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758 use crate::core::auth::TokenStorage;
759 use wiremock::Match;
760 use wiremock::matchers::method;
761 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
762
763 struct QuestionKey(&'static str);
766
767 impl Match for QuestionKey {
768 fn matches(&self, request: &Request) -> bool {
769 serde_json::from_slice::<Value>(&request.body)
770 .ok()
771 .and_then(|body| {
772 body.get("questions")
773 .and_then(Value::as_object)
774 .map(|questions| questions.contains_key(self.0))
775 })
776 .unwrap_or(false)
777 }
778 }
779
780 fn choice_response(id: &str, choice: &str, confidence: f64) -> ResponseTemplate {
781 ResponseTemplate::new(200).set_body_json(json!({
782 "model": "jev-1.13.0",
783 "answers": {
784 id: {
785 "type": "choice",
786 "choice": choice,
787 "probabilities": { choice: confidence },
788 "confidence": confidence,
789 }
790 },
791 "usage": {},
792 }))
793 }
794
795 async fn test_auth() -> GoogleAuth {
796 let config = crate::core::config::OAuthConfig {
797 client_id: "test-id.apps.googleusercontent.com".into(),
798 client_secret: None,
799 };
800 let storage = TokenStorage {
801 access_token: "test-token".into(),
802 refresh_token: None,
803 expires_at: u64::MAX,
804 token_type: "Bearer".into(),
805 scope: String::new(),
806 };
807 GoogleAuth::with_token(config, storage).await.unwrap()
808 }
809
810 fn test_config(endpoint: &str, model: &str) -> GrrConfig {
811 GrrConfig {
812 oauth: crate::core::config::OAuthConfig {
813 client_id: "test-id.apps.googleusercontent.com".into(),
814 client_secret: None,
815 },
816 systemone: crate::core::config::SystemOneConfig {
817 endpoint: endpoint.into(),
818 model: model.into(),
819 api_key: Some("test-systemone-key".into()),
820 confidence_threshold: None,
821 },
822 }
823 }
824
825 fn plan_args(request: &str) -> AskArgs {
826 AskArgs {
827 request: request.into(),
828 run: false,
829 service: None,
830 method: None,
831 dry_run: false,
832 format: OutputFormat::Json,
833 }
834 }
835
836 #[tokio::test]
837 async fn the_hierarchical_flow_resolves_the_right_method_and_params() {
838 let server = MockServer::start().await;
839 Mock::given(method("POST"))
840 .and(QuestionKey("service"))
841 .respond_with(choice_response("service", "gmail", 0.9))
842 .mount(&server)
843 .await;
844 Mock::given(method("POST"))
845 .and(QuestionKey("method"))
846 .respond_with(choice_response("method", "users.threads.get", 0.81))
847 .mount(&server)
848 .await;
849 Mock::given(method("POST"))
852 .and(QuestionKey("userId"))
853 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
854 "model": "jev-1.13.0",
855 "answers": {
856 "userId": {
857 "type": "choice",
858 "choice": "me",
859 "probabilities": { "me": 0.95 },
860 "confidence": 0.95,
861 },
862 "id": {
863 "type": "choice",
864 "choice": "abc123",
865 "probabilities": { "abc123": 0.88 },
866 "confidence": 0.88,
867 },
868 },
869 "usage": {},
870 })))
871 .mount(&server)
872 .await;
873
874 let args = plan_args("show the message thread abc123");
875 let resolution = resolve_ask(
876 &test_config(&server.uri(), ""),
877 &args,
878 &SafetyProfile::PERMISSIVE,
879 )
880 .await
881 .unwrap();
882
883 assert_eq!(resolution.service.name, "gmail");
884 assert_eq!(resolution.method.id, "users.threads.get");
885 assert_eq!(resolution.params.get("userId").unwrap(), "me");
886 assert_eq!(resolution.params.get("id").unwrap(), "abc123");
887 assert_eq!(resolution.confidence, Some(0.81));
888 assert_eq!(resolution.service_confidence, Some(0.9));
889 assert_eq!(resolution.plan.verb, "GET");
890 assert_eq!(
891 resolution.plan.url,
892 "https://gmail.googleapis.com/gmail/v1/users/me/threads/abc123"
893 );
894
895 assert_eq!(server.received_requests().await.unwrap().len(), 3);
897 }
898
899 #[tokio::test]
900 async fn the_no_match_outcome_is_actionable() {
901 let server = MockServer::start().await;
902 Mock::given(method("POST"))
903 .and(QuestionKey("service"))
904 .respond_with(choice_response("service", "none", 0.7))
905 .mount(&server)
906 .await;
907
908 let args = plan_args("book a flight to the moon");
909 let err = resolve_ask(
910 &test_config(&server.uri(), ""),
911 &args,
912 &SafetyProfile::PERMISSIVE,
913 )
914 .await
915 .unwrap_err()
916 .to_string();
917 assert!(err.contains("none of the indexed services"), "{err}");
918 assert!(err.contains("grr api list"), "{err}");
919 }
920
921 #[tokio::test]
922 async fn a_method_no_match_is_actionable_too() {
923 let server = MockServer::start().await;
924 Mock::given(method("POST"))
925 .and(QuestionKey("method"))
926 .respond_with(choice_response("method", "none", 0.7))
927 .mount(&server)
928 .await;
929
930 let mut args = plan_args("book a flight to the moon");
931 args.service = Some("gmail".into());
932 let err = resolve_ask(
933 &test_config(&server.uri(), ""),
934 &args,
935 &SafetyProfile::PERMISSIVE,
936 )
937 .await
938 .unwrap_err()
939 .to_string();
940 assert!(err.contains("none of gmail's"), "{err}");
941 assert!(err.contains("grr api list gmail"), "{err}");
942 }
943
944 #[tokio::test]
945 async fn low_confidence_is_flagged_in_the_resolution() {
946 let server = MockServer::start().await;
947 Mock::given(method("POST"))
948 .and(QuestionKey("method"))
949 .respond_with(choice_response("method", "users.threads.get", 0.42))
950 .mount(&server)
951 .await;
952 Mock::given(method("POST"))
953 .and(QuestionKey("userId"))
954 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
955 "model": "jev-1.13.0",
956 "answers": {
957 "userId": {
958 "type": "choice",
959 "choice": "me",
960 "probabilities": { "me": 0.95 },
961 "confidence": 0.95,
962 },
963 "id": {
964 "type": "choice",
965 "choice": "abc123",
966 "probabilities": { "abc123": 0.9 },
967 "confidence": 0.9,
968 },
969 },
970 "usage": {},
971 })))
972 .mount(&server)
973 .await;
974
975 let mut args = plan_args("show the message thread abc123");
976 args.service = Some("gmail".into());
977 let resolution = resolve_ask(
978 &test_config(&server.uri(), ""),
979 &args,
980 &SafetyProfile::PERMISSIVE,
981 )
982 .await
983 .unwrap();
984 assert!(resolution.low_confidence, "0.42 < 0.6 must flag");
985 assert_eq!(resolution.confidence, Some(0.42));
986
987 let payload = ask_plan_payload(&args.request, &resolution);
988 assert_eq!(payload["lowConfidence"], true);
989 assert_eq!(payload["confidence"], 0.42);
990 }
991
992 #[tokio::test]
993 async fn a_custom_endpoint_and_model_from_config_are_used() {
994 let server = MockServer::start().await;
995 Mock::given(method("POST"))
996 .and(QuestionKey("service"))
997 .respond_with(choice_response("service", "gmail", 0.9))
998 .mount(&server)
999 .await;
1000 Mock::given(method("POST"))
1001 .and(QuestionKey("method"))
1002 .respond_with(choice_response("method", "users.getProfile", 0.9))
1003 .mount(&server)
1004 .await;
1005 Mock::given(method("POST"))
1006 .and(QuestionKey("userId"))
1007 .respond_with(choice_response("userId", "me", 0.95))
1008 .mount(&server)
1009 .await;
1010
1011 let args = plan_args("show my profile");
1012 let resolution = resolve_ask(
1013 &test_config(&server.uri(), "my-jev-fork"),
1014 &args,
1015 &SafetyProfile::PERMISSIVE,
1016 )
1017 .await
1018 .unwrap();
1019
1020 let requests = server.received_requests().await.unwrap();
1024 assert_eq!(requests.len(), 3);
1025 for request in &requests {
1026 let body: Value = serde_json::from_slice(&request.body).unwrap();
1027 assert_eq!(body["model"], "my-jev-fork");
1028 }
1029 assert_eq!(resolution.model, "my-jev-fork");
1030 }
1031
1032 #[tokio::test]
1033 async fn the_api_key_never_enters_a_body_or_the_plan_output() {
1034 let server = MockServer::start().await;
1035 Mock::given(method("POST"))
1036 .and(QuestionKey("method"))
1037 .respond_with(choice_response("method", "users.getProfile", 0.9))
1038 .mount(&server)
1039 .await;
1040 Mock::given(method("POST"))
1041 .and(QuestionKey("userId"))
1042 .respond_with(choice_response("userId", "me", 0.95))
1043 .mount(&server)
1044 .await;
1045
1046 let mut args = plan_args("show my profile");
1047 args.service = Some("gmail".into());
1048 let resolution = resolve_ask(
1049 &test_config(&server.uri(), ""),
1050 &args,
1051 &SafetyProfile::PERMISSIVE,
1052 )
1053 .await
1054 .unwrap();
1055 let payload = ask_plan_payload(&args.request, &resolution);
1056 let plan_json = serde_json::to_string(&payload).unwrap();
1057 assert!(
1058 !plan_json.contains("test-systemone-key"),
1059 "the key leaked into the plan: {plan_json}"
1060 );
1061
1062 for request in server.received_requests().await.unwrap() {
1063 let body = String::from_utf8(request.body.to_vec()).unwrap();
1064 assert!(
1065 !body.contains("test-systemone-key"),
1066 "the key leaked into a request body"
1067 );
1068 let bearer = request
1069 .headers
1070 .get("authorization")
1071 .and_then(|v| v.to_str().ok())
1072 .unwrap_or("");
1073 assert_eq!(
1074 bearer, "Bearer test-systemone-key",
1075 "the key must ride in the Authorization header"
1076 );
1077 }
1078 }
1079
1080 #[tokio::test]
1081 async fn handle_ask_reports_a_missing_api_key_actionably() {
1082 let mut config = test_config("http://127.0.0.1:1/v1/systemone", "");
1083 config.systemone.api_key = None;
1084 let args = plan_args("show my profile");
1085 let err = handle_ask(&config, None, args, &SafetyProfile::PERMISSIVE)
1086 .await
1087 .unwrap_err()
1088 .to_string();
1089 assert!(err.contains("TYPESAFE_API_KEY"), "{err}");
1090 assert!(err.contains("[systemone]"), "{err}");
1091 }
1092
1093 #[tokio::test]
1094 async fn handle_ask_honors_the_safety_gate_before_running() {
1095 let config = test_config("http://127.0.0.1:1/v1/systemone", "");
1098 let mut args = plan_args("send a message");
1099 args.run = true;
1100 args.method = Some("gmail.users.messages.send".into());
1101 let auth = test_auth().await;
1102 let err = handle_ask(&config, Some(&auth), args, &SafetyProfile::readonly())
1103 .await
1104 .unwrap_err()
1105 .to_string();
1106 assert!(err.contains("POST"), "{err}");
1107 }
1108
1109 #[tokio::test]
1110 async fn handle_ask_run_without_a_credential_is_actionable() {
1111 let config = test_config("http://127.0.0.1:1/v1/systemone", "");
1114 let mut args = plan_args("list my drive files");
1115 args.run = true;
1116 args.method = Some("drive.files.list".into());
1117 let err = handle_ask(&config, None, args, &SafetyProfile::PERMISSIVE)
1118 .await
1119 .unwrap_err()
1120 .to_string();
1121 assert!(err.contains("--run"), "{err}");
1122 assert!(err.contains("credential"), "{err}");
1123 }
1124
1125 #[test]
1126 fn every_service_fits_the_choice_option_limit() {
1127 let all = discovery::services();
1130 assert!(all.len() < MAX_CHOICE_OPTIONS);
1131 for (name, service) in all {
1132 assert!(
1133 service.methods.len() < MAX_CHOICE_OPTIONS,
1134 "{name} has {} methods",
1135 service.methods.len()
1136 );
1137 }
1138 }
1139
1140 #[test]
1141 fn param_fill_builds_one_question_per_required_parameter() {
1142 let (_, method) = discovery::resolve("gmail.users.threads.get").unwrap();
1143 let questions = param_fill_questions(&method.description, method, &["abc123".to_owned()]);
1144 assert_eq!(questions.len(), method.required.len());
1145 assert!(questions.contains_key("userId"));
1146 assert!(questions.contains_key("id"));
1147 let user_question = questions.get("userId").unwrap();
1150 assert!(user_question["criteria"].get("me").is_some());
1151 let id_question = questions.get("id").unwrap();
1152 assert!(id_question["criteria"].get("abc123").is_some());
1153 }
1154
1155 #[test]
1156 fn enum_parameters_use_the_enum_values_as_options() {
1157 let param = Parameter {
1158 name: "orderBy".into(),
1159 kind: "string".into(),
1160 required: true,
1161 repeated: false,
1162 location: Some("query".into()),
1163 description: "Sort order".into(),
1164 enum_values: Some(vec!["startTime".into(), "updated".into()]),
1165 };
1166 let question = param_question(¶m, "List events.", &["startTime".into()]);
1167 let options = question["criteria"].as_object().unwrap();
1168 assert!(options.contains_key("startTime"));
1169 assert!(options.contains_key("updated"));
1170 assert!(
1171 !options.contains_key("me"),
1172 "enum options are the closed set"
1173 );
1174 assert!(options.contains_key("none"));
1175 }
1176
1177 #[test]
1178 fn candidates_come_from_the_request_text_verbatim() {
1179 let out = value_candidates("show the file named \"a b.pdf\" with id 42");
1180 assert!(out.contains(&"a b.pdf".to_owned()), "{out:?}");
1181 assert!(out.contains(&"42".to_owned()), "{out:?}");
1182 assert!(out.iter().all(|c| c != "none" && c != "me"));
1183 }
1184
1185 #[test]
1186 fn numeric_parameters_keep_their_json_type() {
1187 let param = Parameter {
1188 name: "pageSize".into(),
1189 kind: "integer".into(),
1190 required: true,
1191 repeated: false,
1192 location: Some("query".into()),
1193 description: "Page size".into(),
1194 enum_values: None,
1195 };
1196 assert_eq!(coerce_value("10", ¶m), json!(10));
1197 assert_eq!(coerce_value("not-a-number", ¶m), json!("not-a-number"));
1198 }
1199
1200 #[test]
1201 fn the_service_rubric_covers_every_indexed_service() {
1202 for (name, service) in discovery::services() {
1203 let rubric = service_rubric(service);
1204 let title: &str = if service.title.is_empty() {
1208 name
1209 } else {
1210 service.title.as_str()
1211 };
1212 assert!(rubric.contains(title), "{name}: {rubric}");
1213 assert!(rubric.contains(&service.version), "{name}: {rubric}");
1214 }
1215 }
1216}