Skip to main content

forge_workspace/mcp/
systemone.rs

1//! System One MCP - ask the configured decision model one typed
2//! question (`mcp__forge__systemone__*`).
3//!
4//! One tool per primitive, each asking exactly one question about one
5//! state; several questions about one state are several parallel calls.
6//! All three are any-caller, and they are injected ONLY when
7//! `[systemone]` is present and enabled - a disabled feature leaves no
8//! tool to call.
9//!
10//! - [`facade`] - the `SystemOneFacade` seam (prod over the boot-built
11//!   client + a mock for tool tests).
12
13use 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
25/// Attach the three systemone tools; called only when a client exists,
26/// for both session kinds (any-caller).
27pub(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
41/// The tool-output shape: what answered, the typed answer, and what it
42/// cost when the provider reported it.
43fn 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    /// The tool text ships to every caller that has the tools, so the two
361    /// sentences that teach the shape a call takes and the budget it must
362    /// fit are pinned here: a silent delete fails instead of shipping.
363    #[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    /// The choice text names its routing moment by the test that decides it:
400    /// no evidence separating the owners. Pinned so the vaguer trigger it
401    /// replaced cannot come back.
402    #[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    /// The choice text asks for the reorder control and tells the model to
415    /// name it in the text. The two sentences ship as a pair: without the
416    /// naming note the second card for a re-asked question draws as a bare
417    /// duplicate, so a trim that drops either half fails here.
418    #[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    /// Every tool names the moment before a decision goes to the user, so a
436    /// rewording that drops it fails instead of shipping. The three are one
437    /// cue at one strength: noul's older "probably" was dropped deliberately
438    /// to match the others, not preserved as a yes/no-specific hedge.
439    #[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    /// The API's own typing: `instructions` and criteria values are any
527    /// JSON, and the tool must carry the structure through verbatim -
528    /// the pre-flight constraints still fire by name around it.
529    #[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    /// What each tool advertises: the structured union the API accepts,
610    /// minus the score tool's levels, which stay strings.
611    #[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}