1use std::sync::Arc;
14
15use forge_sdk::mcp::server::McpServerBuilder;
16use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput};
17use forge_system_one::{AskOutcome, NoulCriteria, Question, SystemOneError};
18
19use crate::mcp::systemone::facade::SystemOneFacade;
20use crate::mcp::systemone::types::{ChoiceArgs, NoulArgs, ScoreArgs};
21
22pub(crate) mod facade;
23pub(crate) mod types;
24
25pub(crate) fn add_tools(
28 builder: McpServerBuilder,
29 facade: Arc<dyn SystemOneFacade>,
30) -> McpServerBuilder {
31 let noul = AskNoul { facade: Arc::clone(&facade) };
32 let choice = AskChoice { facade: Arc::clone(&facade) };
33 let score = AskScore { facade };
34 builder.tool(noul).tool(choice).tool(score)
35}
36
37fn tool_error(text: String) -> ToolOutput {
38 ToolOutput::error(text)
39}
40
41fn outcome_to_text(outcome: &AskOutcome) -> String {
44 let mut value = serde_json::json!({
45 "model": outcome.model,
46 "answer": outcome.answer,
47 });
48 if let Some(usage) = &outcome.usage {
49 value["usage"] = serde_json::json!(usage);
50 }
51 value.to_string()
52}
53
54fn format_ask_error(err: &SystemOneError) -> String {
55 match err {
56 SystemOneError::Http { status: 401, .. } => {
57 "System One rejected the API key (HTTP 401); check `api_key` in forge.toml [systemone]".to_owned()
58 }
59 SystemOneError::Http { status, body } => format!("System One returned HTTP {status}: {body}"),
60 SystemOneError::Timeout => {
61 "System One request timed out (raise `timeout_ms` in forge.toml [systemone] if the state is large)".to_owned()
62 }
63 SystemOneError::Transport(message) => format!("System One request failed: {message}"),
64 SystemOneError::InvalidResponse(detail) => format!("System One returned an invalid answer: {detail}"),
65 }
66}
67
68fn noul_question(args: &NoulArgs) -> Result<Question, String> {
69 let criteria = match &args.criteria {
70 Some(criteria) => {
71 let mut criteria = criteria.clone();
72 let true_value = criteria.remove("true");
73 let false_value = criteria.remove("false");
74 match (true_value, false_value, criteria.is_empty()) {
75 (Some(true_value), Some(false_value), true) => {
76 Some(NoulCriteria { r#true: true_value, r#false: false_value })
77 }
78 _ => {
79 return Err(
80 "noul criteria needs exactly the keys `true` and `false`".to_owned()
81 );
82 }
83 }
84 }
85 None => None,
86 };
87 Ok(Question::Noul { instructions: args.instructions.clone(), criteria })
88}
89
90fn choice_question(args: &ChoiceArgs) -> Result<Question, String> {
91 if args.criteria.is_empty() {
92 return Err("choice criteria needs at least one option".to_owned());
93 }
94 if args.criteria.len() > 255 {
95 return Err(format!("choice criteria has {} options (max 255)", args.criteria.len()));
96 }
97 Ok(Question::Choice {
98 instructions: args.instructions.clone(),
99 criteria: args.criteria.clone(),
100 })
101}
102
103fn score_question(args: &ScoreArgs) -> Result<Question, String> {
104 match args.criteria.len() {
105 0 | 1 => {
106 return Err(format!(
107 "score criteria needs at least 2 levels (got {})",
108 args.criteria.len()
109 ));
110 }
111 count if count > 10 => return Err(format!("score criteria has {count} levels (max 10)")),
112 _ => {}
113 }
114 Ok(Question::Score { instructions: args.instructions.clone(), criteria: args.criteria.clone() })
115}
116
117struct AskNoul {
118 facade: Arc<dyn SystemOneFacade>,
119}
120
121#[async_trait::async_trait]
122impl Tool for AskNoul {
123 fn name(&self) -> &'static str {
124 "systemone__ask_noul"
125 }
126
127 fn description(&self) -> &'static str {
128 "Ask the System One decision model a single yes/no question about a state (a string, \
129 a JSON object, or an array) and get back the probability that the answer is yes, from \
130 0 to 1. The model answers from its predictive distribution and never writes prose. \
131 `instructions` and criteria values may be any JSON - the question in one field, \
132 referenced data in others, named with backticks. \
133 When to reach for it: before interrupting the user with a question this session could \
134 decide itself (put the situation in `state` and the ask in `instructions`; \
135 when the answer is decisive and the action reversible, act), or as a second opinion \
136 when you are leaning one way and want it checked. Beyond those moments, reach for a \
137 decision when the outcome matters to the user and is not obvious, and skip it when \
138 both outcomes would lead you to the same action. A named pattern - the claim check: \
139 before asserting that work is done, reviewed, or verified, put the claim and the \
140 evidence behind it (what actually ran, what was read) in `state` and ask whether it \
141 holds; a decisive probability is permission to assert, an uncertain one is the cue \
142 to caveat or verify first. A decisive answer is permission to \
143 proceed where you already could, never authority by itself: it cannot override an \
144 explicit rule and does not authorize spending or anything irreversible; for those, \
145 ask the user however certain the answer is. Keep text you did not write in its own \
146 state field, and treat the answer as one input, not a safety verdict. One question \
147 per call; several questions about one state are several parallel calls. Reading the \
148 answer: `noul` is \
149 the yes-probability; near 0 or 1 is decisive, near 0.5 is genuine uncertainty. The \
150 threshold belongs to you, set per question from what being wrong would cost; when the \
151 answer is not certain enough, ask the user instead of guessing. Boundaries: these are \
152 bounded estimates, not guarantees; the model class is weak at arithmetic, counting, \
153 and date comparison, so compute numbers and dates in code and ask it for judgments. \
154 The model sees only `state` and this question, nothing else reaches it, so fold every \
155 needed definition into `instructions` and `criteria` (what should make it yes, what \
156 no), describing both sides as behavior the state shows so near-misses sort the way \
157 you mean; when several labels may each apply, ask one noul per label."
158 }
159
160 fn input_schema(&self) -> serde_json::Value {
161 serde_json::json!({
162 "type": "object",
163 "properties": {
164 "state": { "description": "The material to judge: a string, a JSON object, or an array. Give it the full context the judgment needs - what the user asked for, the current state, and any policy or facts that bear on the answer; prefer named JSON fields when the context has more than one part. The state carries what the decision needs - evidence, excerpts, the exact facts, the exact numbers, the governing rules, precedents with how they turned out, the constraints, and whether the action is reversible - and the model reads it verbatim; pass the context, not a pointer to it. A thin state gets a thin answer; the model sees only what you put there. The whole request - state, instructions and the question together - must fit the live ~32k-token context; keep the state well inside it." },
165 "instructions": { "type": ["string", "object", "array", "null"], "description": "The yes/no question itself." },
166 "criteria": {
167 "type": "object",
168 "properties": {
169 "true": { "type": ["string", "object", "array", "null"], "description": "What makes the answer yes." },
170 "false": { "type": ["string", "object", "array", "null"], "description": "What makes the answer no." },
171 },
172 "description": "Optional: what yes and no mean here. Give both keys or omit.",
173 },
174 },
175 "required": ["state", "instructions"],
176 "additionalProperties": false,
177 })
178 }
179
180 async fn call(&self, input: ToolInput) -> ToolOutput {
181 let args: NoulArgs = match serde_json::from_value(input.value) {
182 Ok(args) => args,
183 Err(err) => return tool_error(format!("invalid arguments: {err}")),
184 };
185 let question = match noul_question(&args) {
186 Ok(question) => question,
187 Err(message) => return tool_error(message),
188 };
189 match self.facade.ask(args.state, question).await {
190 Ok(outcome) => ToolOutput::text(outcome_to_text(&outcome)),
191 Err(err) => tool_error(format_ask_error(&err)),
192 }
193 }
194}
195
196struct AskChoice {
197 facade: Arc<dyn SystemOneFacade>,
198}
199
200#[async_trait::async_trait]
201impl Tool for AskChoice {
202 fn name(&self) -> &'static str {
203 "systemone__ask_choice"
204 }
205
206 fn description(&self) -> &'static str {
207 "Ask the System One decision model to choose one option from the set you define, for a \
208 state (a string, a JSON object, or an array). Returns the winning option, the \
209 probability of every option, and a confidence for the distribution. The model never \
210 writes prose. `instructions` and criteria values may be any JSON - the question in \
211 one field, referenced data in others, named with backticks. \
212 When to reach for it: routing and picking between enumerated \
213 alternatives; before interrupting the user with a choice this session could decide \
214 itself; or as a second opinion when you are leaning toward one option and want \
215 the alternatives weighed. Beyond those moments, reach for a decision when the outcome \
216 matters to the user and is not obvious, and skip it when both outcomes would lead you \
217 to the same action; a routing call with several plausible owners and no evidence \
218 separating them is exactly that case. List every option in `criteria`. A null \
219 value is fine when the name stands alone; add a \"when this applies\" clause only \
220 where a subtle distinction needs naming, because nulls are the common case and \
221 six clauses for a five-way choice \
222 cost more than the choice returns (at most 255 options); when nothing may fit the \
223 state, include an explicit no-match option, because the model can only choose among \
224 the options you list. Reading \
225 the answer: `choice` is the highest-probability option; read `probabilities` for the \
226 full distribution, and treat a close runner-up as uncertainty rather than a decision; \
227 escalate to the user when the margin does not clear what the decision costs. \
228 `confidence` is a statistic of the distribution, not a calibrated chance of being \
229 right. Boundaries: one question per call; the model sees only `state` and the options \
230 you list, nothing else reaches it; probabilities are estimates, not guarantees; the \
231 model can favour whichever option is listed first, so when an answer matters, re-ask \
232 with the options reordered and check that it holds. When you run that reorder control, \
233 say so in your text - the two asks draw as two cards, and the second one reads as a \
234 duplicate otherwise."
235 }
236
237 fn input_schema(&self) -> serde_json::Value {
238 serde_json::json!({
239 "type": "object",
240 "properties": {
241 "state": { "description": "The material to judge: a string, a JSON object, or an array. Give it the full context the judgment needs - what the user asked for, the current state, and any policy or facts that bear on the answer; prefer named JSON fields when the context has more than one part. The state carries what the decision needs - evidence, excerpts, the exact facts, the exact numbers, the governing rules, precedents with how they turned out, the constraints, and whether the action is reversible - and the model reads it verbatim; pass the context, not a pointer to it. A thin state gets a thin answer; the model sees only what you put there. The whole request - state, instructions and the question together - must fit the live ~32k-token context; keep the state well inside it." },
242 "instructions": { "type": ["string", "object", "array", "null"], "description": "The question the options answer." },
243 "criteria": {
244 "type": "object",
245 "additionalProperties": { "type": ["string", "object", "array", "null"] },
246 "description": "Option names mapped to when each applies (null when the name stands alone). Every option must be listed.",
247 },
248 },
249 "required": ["state", "instructions", "criteria"],
250 "additionalProperties": false,
251 })
252 }
253
254 async fn call(&self, input: ToolInput) -> ToolOutput {
255 let args: ChoiceArgs = match serde_json::from_value(input.value) {
256 Ok(args) => args,
257 Err(err) => return tool_error(format!("invalid arguments: {err}")),
258 };
259 let question = match choice_question(&args) {
260 Ok(question) => question,
261 Err(message) => return tool_error(message),
262 };
263 match self.facade.ask(args.state, question).await {
264 Ok(outcome) => ToolOutput::text(outcome_to_text(&outcome)),
265 Err(err) => tool_error(format_ask_error(&err)),
266 }
267 }
268}
269
270struct AskScore {
271 facade: Arc<dyn SystemOneFacade>,
272}
273
274#[async_trait::async_trait]
275impl Tool for AskScore {
276 fn name(&self) -> &'static str {
277 "systemone__ask_score"
278 }
279
280 fn description(&self) -> &'static str {
281 "Ask the System One decision model to place a state (a string, a JSON object, or an \
282 array) on an ordered rubric you define. Returns a fractional score (it can land \
283 between levels), the probability of each level, a legend mapping level positions to \
284 your text, and a confidence. The model never writes prose. \
285 `instructions` and criteria values may be any JSON - the question in one field, \
286 referenced data in others, named with backticks. \
287 When to reach for it: \
288 severity, quality, priority, or risk judgments where the levels are meaningful to \
289 you, or before interrupting the user with one this session could decide itself. \
290 Beyond those moments, score a judgment when it matters to the user and is not obvious, \
291 and skip it when the position would not change what you do. \
292 Define `criteria` as an ordered list of level descriptions, lowest first (two to ten \
293 levels); each level should describe a concrete situation and stand on its own. Reading \
294 the answer: `score` is the probability-weighted position (for example 1.79 on a \
295 three-level rubric); `probabilities` shows how settled the answer is; `confidence` is \
296 a statistic of the distribution, not a calibrated chance of being right; the threshold \
297 belongs to you, set from what being wrong would cost; escalate to the user when the \
298 position is not settled enough for the stakes. Boundaries: each level \
299 description is a judgment anchor, not a unit of measure, so do not read gaps between \
300 scores as magnitudes; the model sees only `state` and this rubric, nothing else \
301 reaches it, so fold every needed definition into `instructions` and `criteria`; the \
302 model class is weak at arithmetic and date comparison, so compute numbers in code. \
303 One question per call."
304 }
305
306 fn input_schema(&self) -> serde_json::Value {
307 serde_json::json!({
308 "type": "object",
309 "properties": {
310 "state": { "description": "The material to judge: a string, a JSON object, or an array. Give it the full context the judgment needs - what the user asked for, the current state, and any policy or facts that bear on the answer; prefer named JSON fields when the context has more than one part. The state carries what the decision needs - evidence, excerpts, the exact facts, the exact numbers, the governing rules, precedents with how they turned out, the constraints, and whether the action is reversible - and the model reads it verbatim; pass the context, not a pointer to it. A thin state gets a thin answer; the model sees only what you put there. The whole request - state, instructions and the question together - must fit the live ~32k-token context; keep the state well inside it." },
311 "instructions": { "type": ["string", "object", "array", "null"], "description": "The rubric question itself." },
312 "criteria": {
313 "type": "array",
314 "items": { "type": ["string", "object", "array"] },
315 "minItems": 2,
316 "maxItems": 10,
317 "description": "The ordered level descriptions, lowest first.",
318 },
319 },
320 "required": ["state", "instructions", "criteria"],
321 "additionalProperties": false,
322 })
323 }
324
325 async fn call(&self, input: ToolInput) -> ToolOutput {
326 let args: ScoreArgs = match serde_json::from_value(input.value) {
327 Ok(args) => args,
328 Err(err) => return tool_error(format!("invalid arguments: {err}")),
329 };
330 let question = match score_question(&args) {
331 Ok(question) => question,
332 Err(message) => return tool_error(message),
333 };
334 match self.facade.ask(args.state, question).await {
335 Ok(outcome) => ToolOutput::text(outcome_to_text(&outcome)),
336 Err(err) => tool_error(format_ask_error(&err)),
337 }
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::mcp::systemone::facade::MockSystemOneFacade;
345 use crate::mcp::test_support::text_of;
346 use forge_system_one::{Answer, Usage};
347
348 fn input(value: serde_json::Value) -> ToolInput {
349 ToolInput { value }
350 }
351
352 fn outcome(answer: Answer) -> AskOutcome {
353 AskOutcome {
354 model: "test-model".to_owned(),
355 answer,
356 usage: Some(Usage { input_tokens: 10, output_tokens: 3, cost: None }),
357 }
358 }
359
360 #[test]
364 fn the_tool_text_carries_its_structured_values_and_budget_sentences() {
365 let noul = AskNoul { facade: MockSystemOneFacade::new().into_arc() };
366 let choice = AskChoice { facade: MockSystemOneFacade::new().into_arc() };
367 let score = AskScore { facade: MockSystemOneFacade::new().into_arc() };
368 for (tool, description, schema) in [
369 ("systemone__ask_noul", noul.description(), noul.input_schema()),
370 ("systemone__ask_choice", choice.description(), choice.input_schema()),
371 ("systemone__ask_score", score.description(), score.input_schema()),
372 ] {
373 assert!(
374 description.contains(
375 "`instructions` and criteria values may be any JSON - the question in one \
376 field, referenced data in others, named with backticks"
377 ),
378 "{tool} must teach the structured values it takes: {description}"
379 );
380 let state = schema["properties"]["state"]["description"]
381 .as_str()
382 .expect("the state description is a string");
383 for clause in [
384 "The state carries what the decision needs",
385 "the exact numbers, the governing rules, precedents with how they turned out, \
386 the constraints, and whether the action is reversible",
387 "A thin state gets a thin answer; the model sees only what you put there",
388 "pass the context, not a pointer to it",
389 "must fit the live ~32k-token context; keep the state well inside it",
390 ] {
391 assert!(
392 state.contains(clause),
393 "{tool}'s state description owes {clause:?}: {state}"
394 );
395 }
396 }
397 }
398
399 #[test]
403 fn the_choice_description_names_its_routing_evidence_test() {
404 let choice = AskChoice { facade: MockSystemOneFacade::new().into_arc() };
405 assert!(
406 choice.description().contains(
407 "a routing call with several plausible owners and no evidence separating them"
408 ),
409 "the routing moment's evidence test is pinned: {}",
410 choice.description()
411 );
412 }
413
414 #[test]
419 fn the_choice_description_names_its_reorder_control() {
420 let choice = AskChoice { facade: MockSystemOneFacade::new().into_arc() };
421 assert!(
422 choice
423 .description()
424 .contains("re-ask with the options reordered and check that it holds"),
425 "the reorder-control cue is pinned: {}",
426 choice.description()
427 );
428 assert!(
429 choice.description().contains("say so in your text"),
430 "the note that names the reorder control is pinned: {}",
431 choice.description()
432 );
433 }
434
435 #[test]
440 fn the_tool_texts_name_their_escalate_moments() {
441 let noul = AskNoul { facade: MockSystemOneFacade::new().into_arc() };
442 let choice = AskChoice { facade: MockSystemOneFacade::new().into_arc() };
443 let score = AskScore { facade: MockSystemOneFacade::new().into_arc() };
444 assert!(
445 noul.description().contains(
446 "before interrupting the user with a question this session could decide itself"
447 ),
448 "noul's escalate moment is pinned: {}",
449 noul.description()
450 );
451 assert!(
452 noul.description()
453 .contains("when the answer is decisive and the action reversible, act"),
454 "noul's act-on-decisive-and-reversible clause is pinned: {}",
455 noul.description()
456 );
457 assert!(
458 choice.description().contains(
459 "before interrupting the user with a choice this session could decide itself"
460 ),
461 "choice's escalate moment is pinned: {}",
462 choice.description()
463 );
464 assert!(
465 score
466 .description()
467 .contains("before interrupting the user with one this session could decide itself"),
468 "score's escalate moment is pinned: {}",
469 score.description()
470 );
471 }
472
473 #[tokio::test]
474 async fn ask_noul_omits_absent_usage_from_the_output() {
475 let mock = Arc::new(MockSystemOneFacade::new());
476 *mock.result.lock() = Some(Ok(AskOutcome {
477 model: "m".to_owned(),
478 answer: Answer::Noul { noul: 0.5 },
479 usage: None,
480 }));
481 let tool = AskNoul { facade: mock.clone() };
482
483 let out = tool.call(input(serde_json::json!({"state": "x", "instructions": "y"}))).await;
484
485 assert!(
486 !out.is_error,
487 "a usage-less answer is still a delivered decision: {}",
488 text_of(&out)
489 );
490 assert!(!text_of(&out).contains("usage"), "no invented usage block: {}", text_of(&out));
491 assert!(text_of(&out).contains("\"noul\":0.5"), "{}", text_of(&out));
492 }
493
494 #[tokio::test]
495 async fn ask_noul_returns_the_typed_answer_and_reaches_the_facade() {
496 let mock = Arc::new(MockSystemOneFacade::new());
497 *mock.result.lock() = Some(Ok(outcome(Answer::Noul { noul: 0.83 })));
498 let tool = AskNoul { facade: mock.clone() };
499
500 let out = tool
501 .call(input(serde_json::json!({
502 "state": {"ticket": "x"},
503 "instructions": "Is this a billing issue?",
504 "criteria": {"true": "Payments", "false": "Anything else"}
505 })))
506 .await;
507
508 assert!(!out.is_error, "noul succeeds: {}", text_of(&out));
509 assert!(text_of(&out).contains("\"noul\":0.83"), "{}", text_of(&out));
510 assert!(text_of(&out).contains("test-model"), "{}", text_of(&out));
511 let calls = mock.calls.lock();
512 assert_eq!(calls.len(), 1);
513 assert_eq!(calls[0].0, serde_json::json!({"ticket": "x"}));
514 assert_eq!(
515 calls[0].1,
516 Question::Noul {
517 instructions: serde_json::json!("Is this a billing issue?"),
518 criteria: Some(NoulCriteria {
519 r#true: serde_json::json!("Payments"),
520 r#false: serde_json::json!("Anything else")
521 }),
522 }
523 );
524 }
525
526 #[tokio::test]
530 async fn structured_values_reach_the_facade_verbatim() {
531 let mock = Arc::new(MockSystemOneFacade::new());
532 *mock.result.lock() = Some(Ok(outcome(Answer::Noul { noul: 0.83 })));
533
534 let noul = AskNoul { facade: mock.clone() };
535 let out = noul
536 .call(input(serde_json::json!({
537 "state": {"ticket": "x"},
538 "instructions": {"question": "Is this a billing issue?", "policy": "refunds within 30 days"},
539 "criteria": {"true": {"rule": "Payments"}, "false": "Anything else"}
540 })))
541 .await;
542 assert!(!out.is_error, "structured noul arguments are accepted: {}", text_of(&out));
543
544 let choice = AskChoice { facade: mock.clone() };
545 let out = choice
546 .call(input(serde_json::json!({
547 "state": "x",
548 "instructions": {"question": "Which team?", "touched": ["a.rs"]},
549 "criteria": {"billing": {"files": ["a.rs"]}, "frontend": null}
550 })))
551 .await;
552 assert!(!out.is_error, "structured choice values are accepted: {}", text_of(&out));
553
554 let score = AskScore { facade: mock.clone() };
555 let out = score
556 .call(input(serde_json::json!({
557 "state": "x",
558 "instructions": {"question": "How urgent?", "note": "per `sev.md`"},
559 "criteria": ["Routine", {"label": "Urgent", "scope": "page now"}]
560 })))
561 .await;
562 assert!(!out.is_error, "a structured score question is accepted: {}", text_of(&out));
563
564 let malformed = noul
565 .call(input(serde_json::json!({
566 "state": "x",
567 "instructions": "Is it?",
568 "criteria": {"true": {"rule": "yes"}, "false": "no", "maybe": "hmm"}
569 })))
570 .await;
571 assert!(malformed.is_error, "a third noul key is still refused");
572 assert!(
573 text_of(&malformed).contains("exactly the keys `true` and `false`"),
574 "{}",
575 text_of(&malformed)
576 );
577
578 let calls = mock.calls.lock();
579 assert_eq!(calls.len(), 3, "the malformed call never reaches the facade");
580 assert_eq!(
581 serde_json::to_value(&calls[0].1).expect("question serializes"),
582 serde_json::json!({
583 "type": "noul",
584 "instructions": {"question": "Is this a billing issue?", "policy": "refunds within 30 days"},
585 "criteria": {"true": {"rule": "Payments"}, "false": "Anything else"}
586 }),
587 "structured noul values reach the wire verbatim"
588 );
589 assert_eq!(
590 serde_json::to_value(&calls[1].1).expect("question serializes"),
591 serde_json::json!({
592 "type": "choice",
593 "instructions": {"question": "Which team?", "touched": ["a.rs"]},
594 "criteria": {"billing": {"files": ["a.rs"]}, "frontend": null}
595 }),
596 "structured choice values reach the wire verbatim"
597 );
598 assert_eq!(
599 serde_json::to_value(&calls[2].1).expect("question serializes"),
600 serde_json::json!({
601 "type": "score",
602 "instructions": {"question": "How urgent?", "note": "per `sev.md`"},
603 "criteria": ["Routine", {"label": "Urgent", "scope": "page now"}]
604 }),
605 "structured score levels reach the wire verbatim"
606 );
607 }
608
609 #[test]
612 fn schemas_advertise_the_structured_union() {
613 let noul = AskNoul { facade: MockSystemOneFacade::new().into_arc() }.input_schema();
614 let choice = AskChoice { facade: MockSystemOneFacade::new().into_arc() }.input_schema();
615 let score = AskScore { facade: MockSystemOneFacade::new().into_arc() }.input_schema();
616
617 let union = serde_json::json!(["string", "object", "array", "null"]);
618 assert_eq!(
619 noul["properties"]["instructions"]["type"], union,
620 "noul instructions advertise the API's union"
621 );
622 assert_eq!(noul["properties"]["criteria"]["properties"]["true"]["type"], union);
623 assert_eq!(noul["properties"]["criteria"]["properties"]["false"]["type"], union);
624 assert_eq!(
625 choice["properties"]["criteria"]["additionalProperties"]["type"], union,
626 "choice option values advertise the API's union"
627 );
628 assert_eq!(
629 score["properties"]["criteria"]["items"]["type"],
630 serde_json::json!(["string", "object", "array"]),
631 "score levels advertise the API's union, which has no null"
632 );
633 for (tool, schema) in [("noul", &noul), ("choice", &choice), ("score", &score)] {
634 assert_eq!(
635 schema["properties"]["instructions"]["type"], union,
636 "{tool} instructions advertise the API's union"
637 );
638 }
639 }
640
641 #[tokio::test]
642 async fn ask_noul_criteria_needs_exactly_true_and_false() {
643 let mock = Arc::new(MockSystemOneFacade::new());
644 let tool = AskNoul { facade: mock.clone() };
645
646 let out = tool.call(input(serde_json::json!({"state": "x", "instructions": "Is it?", "criteria": {"true": "yes"}}))).await;
647
648 assert!(out.is_error);
649 assert!(text_of(&out).contains("exactly the keys `true` and `false`"), "{}", text_of(&out));
650 assert!(mock.calls.lock().is_empty(), "malformed criteria never reaches the facade");
651 }
652
653 #[tokio::test]
654 async fn ask_choice_returns_the_choice_answer() {
655 let mock = Arc::new(MockSystemOneFacade::new());
656 let answer = Answer::Choice {
657 choice: "billing".to_owned(),
658 probabilities: Some(
659 [("billing".to_owned(), 0.9), ("frontend".to_owned(), 0.1)].into_iter().collect(),
660 ),
661 confidence: Some(0.8),
662 };
663 *mock.result.lock() = Some(Ok(outcome(answer)));
664 let tool = AskChoice { facade: mock.clone() };
665
666 let out = tool.call(input(serde_json::json!({"state": "x", "instructions": "Which team?", "criteria": {"billing": "Payments", "frontend": null}}))).await;
667
668 assert!(!out.is_error, "choice succeeds: {}", text_of(&out));
669 assert!(text_of(&out).contains("\"choice\":\"billing\""), "{}", text_of(&out));
670 assert!(text_of(&out).contains("\"probabilities\""), "{}", text_of(&out));
671 }
672
673 #[tokio::test]
674 async fn ask_choice_rejects_empty_criteria() {
675 let mock = Arc::new(MockSystemOneFacade::new());
676 let tool = AskChoice { facade: mock.clone() };
677
678 let out = tool
679 .call(input(
680 serde_json::json!({"state": "x", "instructions": "Which?", "criteria": {}}),
681 ))
682 .await;
683
684 assert!(out.is_error);
685 assert!(text_of(&out).contains("at least one option"), "{}", text_of(&out));
686 assert!(mock.calls.lock().is_empty());
687 }
688
689 #[tokio::test]
690 async fn ask_choice_rejects_too_many_options() {
691 let mock = Arc::new(MockSystemOneFacade::new());
692 let tool = AskChoice { facade: mock.clone() };
693 let criteria: serde_json::Map<String, serde_json::Value> =
694 (0..256).map(|index| (format!("o{index}"), serde_json::Value::Null)).collect();
695
696 let out = tool
697 .call(input(
698 serde_json::json!({"state": "x", "instructions": "Which?", "criteria": criteria}),
699 ))
700 .await;
701
702 assert!(out.is_error);
703 assert!(text_of(&out).contains("256 options (max 255)"), "{}", text_of(&out));
704 assert!(mock.calls.lock().is_empty());
705 }
706
707 #[tokio::test]
708 async fn ask_score_returns_the_rubric_answer() {
709 let mock = Arc::new(MockSystemOneFacade::new());
710 let answer = Answer::Score {
711 score: 1.79,
712 probabilities: Some(
713 [("0".to_owned(), 0.1), ("1".to_owned(), 0.3), ("2".to_owned(), 0.6)]
714 .into_iter()
715 .collect(),
716 ),
717 confidence: Some(0.5),
718 legend: Some(
719 [
720 ("0".to_owned(), "Routine".to_owned()),
721 ("1".to_owned(), "Soon".to_owned()),
722 ("2".to_owned(), "Urgent".to_owned()),
723 ]
724 .into_iter()
725 .collect(),
726 ),
727 };
728 *mock.result.lock() = Some(Ok(outcome(answer)));
729 let tool = AskScore { facade: mock.clone() };
730
731 let out = tool
732 .call(input(serde_json::json!({"state": "x", "instructions": "How urgent?", "criteria": ["Routine", "Soon", "Urgent"]})))
733 .await;
734
735 assert!(!out.is_error, "score succeeds: {}", text_of(&out));
736 assert!(text_of(&out).contains("\"score\":1.79"), "{}", text_of(&out));
737 assert!(text_of(&out).contains("\"legend\""), "{}", text_of(&out));
738 assert!(text_of(&out).contains("\"probabilities\""), "{}", text_of(&out));
739 let calls = mock.calls.lock();
740 assert_eq!(calls.len(), 1);
741 assert_eq!(
742 calls[0].1,
743 Question::Score {
744 instructions: serde_json::json!("How urgent?"),
745 criteria: vec![
746 serde_json::json!("Routine"),
747 serde_json::json!("Soon"),
748 serde_json::json!("Urgent"),
749 ],
750 }
751 );
752 }
753
754 #[tokio::test]
755 async fn boundary_criteria_are_accepted_and_reach_the_facade() {
756 let mock = Arc::new(MockSystemOneFacade::new());
757 let choice = AskChoice { facade: mock.clone() };
758
759 let out = choice.call(input(serde_json::json!({"state": "x", "instructions": "Which?", "criteria": {"only": null}}))).await;
760 assert!(!out.is_error, "one option is accepted: {}", text_of(&out));
761
762 let wide: serde_json::Map<String, serde_json::Value> =
763 (0..255).map(|index| (format!("o{index}"), serde_json::Value::Null)).collect();
764 let out = choice
765 .call(input(
766 serde_json::json!({"state": "x", "instructions": "Which?", "criteria": wide}),
767 ))
768 .await;
769 assert!(!out.is_error, "255 options are accepted: {}", text_of(&out));
770
771 let score = AskScore { facade: mock.clone() };
772 let out = score.call(input(serde_json::json!({"state": "x", "instructions": "How bad?", "criteria": ["low", "high"]}))).await;
773 assert!(!out.is_error, "two levels are accepted: {}", text_of(&out));
774
775 assert_eq!(mock.calls.lock().len(), 3, "every boundary case reached the facade");
776 }
777
778 #[tokio::test]
779 async fn ask_score_rejects_one_level() {
780 let mock = Arc::new(MockSystemOneFacade::new());
781 let tool = AskScore { facade: mock.clone() };
782
783 let out = tool
784 .call(input(
785 serde_json::json!({"state": "x", "instructions": "How bad?", "criteria": ["only"]}),
786 ))
787 .await;
788
789 assert!(out.is_error);
790 assert!(text_of(&out).contains("at least 2 levels (got 1)"), "{}", text_of(&out));
791 assert!(mock.calls.lock().is_empty());
792 }
793
794 #[tokio::test]
795 async fn ask_score_rejects_eleven_levels() {
796 let mock = Arc::new(MockSystemOneFacade::new());
797 let tool = AskScore { facade: mock.clone() };
798 let criteria: Vec<String> = (0..11).map(|index| format!("level {index}")).collect();
799
800 let out = tool
801 .call(input(
802 serde_json::json!({"state": "x", "instructions": "How bad?", "criteria": criteria}),
803 ))
804 .await;
805
806 assert!(out.is_error);
807 assert!(text_of(&out).contains("11 levels (max 10)"), "{}", text_of(&out));
808 assert!(mock.calls.lock().is_empty());
809 }
810
811 #[tokio::test]
812 async fn a_401_names_the_config_key() {
813 let mock = Arc::new(MockSystemOneFacade::new());
814 *mock.result.lock() =
815 Some(Err(SystemOneError::Http { status: 401, body: "{\"detail\":[]}".to_owned() }));
816 let tool = AskNoul { facade: mock.clone() };
817
818 let out = tool.call(input(serde_json::json!({"state": "x", "instructions": "y"}))).await;
819
820 assert!(out.is_error);
821 assert!(text_of(&out).contains("rejected the API key"), "{}", text_of(&out));
822 assert!(text_of(&out).contains("`api_key` in forge.toml [systemone]"), "{}", text_of(&out));
823 }
824
825 #[tokio::test]
826 async fn provider_errors_carry_status_and_body() {
827 let mock = Arc::new(MockSystemOneFacade::new());
828 *mock.result.lock() = Some(Err(SystemOneError::Http {
829 status: 422,
830 body: "{\"detail\":[{\"msg\":\"Field required\"}]}".to_owned(),
831 }));
832 let tool = AskNoul { facade: mock.clone() };
833
834 let out = tool.call(input(serde_json::json!({"state": "x", "instructions": "y"}))).await;
835
836 assert!(out.is_error);
837 assert!(text_of(&out).contains("HTTP 422"), "{}", text_of(&out));
838 assert!(text_of(&out).contains("Field required"), "{}", text_of(&out));
839 }
840
841 #[tokio::test]
842 async fn a_transport_failure_names_itself() {
843 let mock = Arc::new(MockSystemOneFacade::new());
844 *mock.result.lock() = Some(Err(SystemOneError::Transport("connection refused".to_owned())));
845 let tool = AskNoul { facade: mock.clone() };
846
847 let out = tool.call(input(serde_json::json!({"state": "x", "instructions": "y"}))).await;
848
849 assert!(out.is_error);
850 assert!(
851 text_of(&out).contains("System One request failed: connection refused"),
852 "{}",
853 text_of(&out)
854 );
855 }
856
857 #[tokio::test]
858 async fn timeout_and_invalid_response_map_to_their_texts() {
859 let mock = Arc::new(MockSystemOneFacade::new());
860 let tool = AskNoul { facade: mock.clone() };
861
862 *mock.result.lock() = Some(Err(SystemOneError::Timeout));
863 let out = tool.call(input(serde_json::json!({"state": "x", "instructions": "y"}))).await;
864 assert!(out.is_error);
865 assert!(text_of(&out).contains("timed out"), "{}", text_of(&out));
866 assert!(
867 text_of(&out).contains("timeout_ms"),
868 "the hint half is the deviation the PR body rests on: {}",
869 text_of(&out)
870 );
871
872 *mock.result.lock() =
873 Some(Err(SystemOneError::InvalidResponse("probability keys drift".to_owned())));
874 let out = tool.call(input(serde_json::json!({"state": "x", "instructions": "y"}))).await;
875 assert!(out.is_error);
876 assert!(
877 text_of(&out).contains("invalid answer: probability keys drift"),
878 "{}",
879 text_of(&out)
880 );
881 }
882}