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 ..Default::default()
800 };
801 let storage = TokenStorage {
802 access_token: "test-token".into(),
803 refresh_token: None,
804 expires_at: u64::MAX,
805 token_type: "Bearer".into(),
806 scope: String::new(),
807 };
808 GoogleAuth::with_token(config, storage).await.unwrap()
809 }
810
811 fn test_config(endpoint: &str, model: &str) -> GrrConfig {
812 GrrConfig {
813 oauth: crate::core::config::OAuthConfig {
814 client_id: "test-id.apps.googleusercontent.com".into(),
815 client_secret: None,
816 ..Default::default()
817 },
818 systemone: crate::core::config::SystemOneConfig {
819 endpoint: endpoint.into(),
820 model: model.into(),
821 api_key: Some("test-systemone-key".into()),
822 confidence_threshold: None,
823 },
824 }
825 }
826
827 fn plan_args(request: &str) -> AskArgs {
828 AskArgs {
829 request: request.into(),
830 run: false,
831 service: None,
832 method: None,
833 dry_run: false,
834 format: OutputFormat::Json,
835 }
836 }
837
838 #[tokio::test]
839 async fn the_hierarchical_flow_resolves_the_right_method_and_params() {
840 let server = MockServer::start().await;
841 Mock::given(method("POST"))
842 .and(QuestionKey("service"))
843 .respond_with(choice_response("service", "gmail", 0.9))
844 .mount(&server)
845 .await;
846 Mock::given(method("POST"))
847 .and(QuestionKey("method"))
848 .respond_with(choice_response("method", "users.threads.get", 0.81))
849 .mount(&server)
850 .await;
851 Mock::given(method("POST"))
854 .and(QuestionKey("userId"))
855 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
856 "model": "jev-1.13.0",
857 "answers": {
858 "userId": {
859 "type": "choice",
860 "choice": "me",
861 "probabilities": { "me": 0.95 },
862 "confidence": 0.95,
863 },
864 "id": {
865 "type": "choice",
866 "choice": "abc123",
867 "probabilities": { "abc123": 0.88 },
868 "confidence": 0.88,
869 },
870 },
871 "usage": {},
872 })))
873 .mount(&server)
874 .await;
875
876 let args = plan_args("show the message thread abc123");
877 let resolution = resolve_ask(
878 &test_config(&server.uri(), ""),
879 &args,
880 &SafetyProfile::PERMISSIVE,
881 )
882 .await
883 .unwrap();
884
885 assert_eq!(resolution.service.name, "gmail");
886 assert_eq!(resolution.method.id, "users.threads.get");
887 assert_eq!(resolution.params.get("userId").unwrap(), "me");
888 assert_eq!(resolution.params.get("id").unwrap(), "abc123");
889 assert_eq!(resolution.confidence, Some(0.81));
890 assert_eq!(resolution.service_confidence, Some(0.9));
891 assert_eq!(resolution.plan.verb, "GET");
892 assert_eq!(
893 resolution.plan.url,
894 "https://gmail.googleapis.com/gmail/v1/users/me/threads/abc123"
895 );
896
897 assert_eq!(server.received_requests().await.unwrap().len(), 3);
899 }
900
901 #[tokio::test]
902 async fn the_no_match_outcome_is_actionable() {
903 let server = MockServer::start().await;
904 Mock::given(method("POST"))
905 .and(QuestionKey("service"))
906 .respond_with(choice_response("service", "none", 0.7))
907 .mount(&server)
908 .await;
909
910 let args = plan_args("book a flight to the moon");
911 let err = resolve_ask(
912 &test_config(&server.uri(), ""),
913 &args,
914 &SafetyProfile::PERMISSIVE,
915 )
916 .await
917 .unwrap_err()
918 .to_string();
919 assert!(err.contains("none of the indexed services"), "{err}");
920 assert!(err.contains("grr api list"), "{err}");
921 }
922
923 #[tokio::test]
924 async fn a_method_no_match_is_actionable_too() {
925 let server = MockServer::start().await;
926 Mock::given(method("POST"))
927 .and(QuestionKey("method"))
928 .respond_with(choice_response("method", "none", 0.7))
929 .mount(&server)
930 .await;
931
932 let mut args = plan_args("book a flight to the moon");
933 args.service = Some("gmail".into());
934 let err = resolve_ask(
935 &test_config(&server.uri(), ""),
936 &args,
937 &SafetyProfile::PERMISSIVE,
938 )
939 .await
940 .unwrap_err()
941 .to_string();
942 assert!(err.contains("none of gmail's"), "{err}");
943 assert!(err.contains("grr api list gmail"), "{err}");
944 }
945
946 #[tokio::test]
947 async fn low_confidence_is_flagged_in_the_resolution() {
948 let server = MockServer::start().await;
949 Mock::given(method("POST"))
950 .and(QuestionKey("method"))
951 .respond_with(choice_response("method", "users.threads.get", 0.42))
952 .mount(&server)
953 .await;
954 Mock::given(method("POST"))
955 .and(QuestionKey("userId"))
956 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
957 "model": "jev-1.13.0",
958 "answers": {
959 "userId": {
960 "type": "choice",
961 "choice": "me",
962 "probabilities": { "me": 0.95 },
963 "confidence": 0.95,
964 },
965 "id": {
966 "type": "choice",
967 "choice": "abc123",
968 "probabilities": { "abc123": 0.9 },
969 "confidence": 0.9,
970 },
971 },
972 "usage": {},
973 })))
974 .mount(&server)
975 .await;
976
977 let mut args = plan_args("show the message thread abc123");
978 args.service = Some("gmail".into());
979 let resolution = resolve_ask(
980 &test_config(&server.uri(), ""),
981 &args,
982 &SafetyProfile::PERMISSIVE,
983 )
984 .await
985 .unwrap();
986 assert!(resolution.low_confidence, "0.42 < 0.6 must flag");
987 assert_eq!(resolution.confidence, Some(0.42));
988
989 let payload = ask_plan_payload(&args.request, &resolution);
990 assert_eq!(payload["lowConfidence"], true);
991 assert_eq!(payload["confidence"], 0.42);
992 }
993
994 #[tokio::test]
995 async fn a_custom_endpoint_and_model_from_config_are_used() {
996 let server = MockServer::start().await;
997 Mock::given(method("POST"))
998 .and(QuestionKey("service"))
999 .respond_with(choice_response("service", "gmail", 0.9))
1000 .mount(&server)
1001 .await;
1002 Mock::given(method("POST"))
1003 .and(QuestionKey("method"))
1004 .respond_with(choice_response("method", "users.getProfile", 0.9))
1005 .mount(&server)
1006 .await;
1007 Mock::given(method("POST"))
1008 .and(QuestionKey("userId"))
1009 .respond_with(choice_response("userId", "me", 0.95))
1010 .mount(&server)
1011 .await;
1012
1013 let args = plan_args("show my profile");
1014 let resolution = resolve_ask(
1015 &test_config(&server.uri(), "my-jev-fork"),
1016 &args,
1017 &SafetyProfile::PERMISSIVE,
1018 )
1019 .await
1020 .unwrap();
1021
1022 let requests = server.received_requests().await.unwrap();
1026 assert_eq!(requests.len(), 3);
1027 for request in &requests {
1028 let body: Value = serde_json::from_slice(&request.body).unwrap();
1029 assert_eq!(body["model"], "my-jev-fork");
1030 }
1031 assert_eq!(resolution.model, "my-jev-fork");
1032 }
1033
1034 #[tokio::test]
1035 async fn the_api_key_never_enters_a_body_or_the_plan_output() {
1036 let server = MockServer::start().await;
1037 Mock::given(method("POST"))
1038 .and(QuestionKey("method"))
1039 .respond_with(choice_response("method", "users.getProfile", 0.9))
1040 .mount(&server)
1041 .await;
1042 Mock::given(method("POST"))
1043 .and(QuestionKey("userId"))
1044 .respond_with(choice_response("userId", "me", 0.95))
1045 .mount(&server)
1046 .await;
1047
1048 let mut args = plan_args("show my profile");
1049 args.service = Some("gmail".into());
1050 let resolution = resolve_ask(
1051 &test_config(&server.uri(), ""),
1052 &args,
1053 &SafetyProfile::PERMISSIVE,
1054 )
1055 .await
1056 .unwrap();
1057 let payload = ask_plan_payload(&args.request, &resolution);
1058 let plan_json = serde_json::to_string(&payload).unwrap();
1059 assert!(
1060 !plan_json.contains("test-systemone-key"),
1061 "the key leaked into the plan: {plan_json}"
1062 );
1063
1064 for request in server.received_requests().await.unwrap() {
1065 let body = String::from_utf8(request.body.to_vec()).unwrap();
1066 assert!(
1067 !body.contains("test-systemone-key"),
1068 "the key leaked into a request body"
1069 );
1070 let bearer = request
1071 .headers
1072 .get("authorization")
1073 .and_then(|v| v.to_str().ok())
1074 .unwrap_or("");
1075 assert_eq!(
1076 bearer, "Bearer test-systemone-key",
1077 "the key must ride in the Authorization header"
1078 );
1079 }
1080 }
1081
1082 #[tokio::test]
1083 async fn handle_ask_reports_a_missing_api_key_actionably() {
1084 let mut config = test_config("http://127.0.0.1:1/v1/systemone", "");
1085 config.systemone.api_key = None;
1086 let args = plan_args("show my profile");
1087 let err = handle_ask(&config, None, args, &SafetyProfile::PERMISSIVE)
1088 .await
1089 .unwrap_err()
1090 .to_string();
1091 assert!(err.contains("TYPESAFE_API_KEY"), "{err}");
1092 assert!(err.contains("[systemone]"), "{err}");
1093 }
1094
1095 #[tokio::test]
1096 async fn handle_ask_honors_the_safety_gate_before_running() {
1097 let config = test_config("http://127.0.0.1:1/v1/systemone", "");
1100 let mut args = plan_args("send a message");
1101 args.run = true;
1102 args.method = Some("gmail.users.messages.send".into());
1103 let auth = test_auth().await;
1104 let err = handle_ask(
1105 &config,
1106 Some(&auth),
1107 args,
1108 &SafetyProfile::new(true, [], []),
1109 )
1110 .await
1111 .unwrap_err()
1112 .to_string();
1113 assert!(err.contains("POST"), "{err}");
1114 }
1115
1116 #[tokio::test]
1117 async fn handle_ask_run_without_a_credential_is_actionable() {
1118 let config = test_config("http://127.0.0.1:1/v1/systemone", "");
1121 let mut args = plan_args("list my drive files");
1122 args.run = true;
1123 args.method = Some("drive.files.list".into());
1124 let err = handle_ask(&config, None, args, &SafetyProfile::PERMISSIVE)
1125 .await
1126 .unwrap_err()
1127 .to_string();
1128 assert!(err.contains("--run"), "{err}");
1129 assert!(err.contains("credential"), "{err}");
1130 }
1131
1132 #[test]
1133 fn every_service_fits_the_choice_option_limit() {
1134 let all = discovery::services();
1137 assert!(all.len() < MAX_CHOICE_OPTIONS);
1138 for (name, service) in all {
1139 assert!(
1140 service.methods.len() < MAX_CHOICE_OPTIONS,
1141 "{name} has {} methods",
1142 service.methods.len()
1143 );
1144 }
1145 }
1146
1147 #[test]
1148 fn param_fill_builds_one_question_per_required_parameter() {
1149 let (_, method) = discovery::resolve("gmail.users.threads.get").unwrap();
1150 let questions = param_fill_questions(&method.description, method, &["abc123".to_owned()]);
1151 assert_eq!(questions.len(), method.required.len());
1152 assert!(questions.contains_key("userId"));
1153 assert!(questions.contains_key("id"));
1154 let user_question = questions.get("userId").unwrap();
1157 assert!(user_question["criteria"].get("me").is_some());
1158 let id_question = questions.get("id").unwrap();
1159 assert!(id_question["criteria"].get("abc123").is_some());
1160 }
1161
1162 #[test]
1163 fn enum_parameters_use_the_enum_values_as_options() {
1164 let param = Parameter {
1165 name: "orderBy".into(),
1166 kind: "string".into(),
1167 required: true,
1168 repeated: false,
1169 location: Some("query".into()),
1170 description: "Sort order".into(),
1171 enum_values: Some(vec!["startTime".into(), "updated".into()]),
1172 };
1173 let question = param_question(¶m, "List events.", &["startTime".into()]);
1174 let options = question["criteria"].as_object().unwrap();
1175 assert!(options.contains_key("startTime"));
1176 assert!(options.contains_key("updated"));
1177 assert!(
1178 !options.contains_key("me"),
1179 "enum options are the closed set"
1180 );
1181 assert!(options.contains_key("none"));
1182 }
1183
1184 #[test]
1185 fn candidates_come_from_the_request_text_verbatim() {
1186 let out = value_candidates("show the file named \"a b.pdf\" with id 42");
1187 assert!(out.contains(&"a b.pdf".to_owned()), "{out:?}");
1188 assert!(out.contains(&"42".to_owned()), "{out:?}");
1189 assert!(out.iter().all(|c| c != "none" && c != "me"));
1190 }
1191
1192 #[test]
1193 fn numeric_parameters_keep_their_json_type() {
1194 let param = Parameter {
1195 name: "pageSize".into(),
1196 kind: "integer".into(),
1197 required: true,
1198 repeated: false,
1199 location: Some("query".into()),
1200 description: "Page size".into(),
1201 enum_values: None,
1202 };
1203 assert_eq!(coerce_value("10", ¶m), json!(10));
1204 assert_eq!(coerce_value("not-a-number", ¶m), json!("not-a-number"));
1205 }
1206
1207 #[test]
1208 fn the_service_rubric_covers_every_indexed_service() {
1209 for (name, service) in discovery::services() {
1210 let rubric = service_rubric(service);
1211 let title: &str = if service.title.is_empty() {
1215 name
1216 } else {
1217 service.title.as_str()
1218 };
1219 assert!(rubric.contains(title), "{name}: {rubric}");
1220 assert!(rubric.contains(&service.version), "{name}: {rubric}");
1221 }
1222 }
1223}