Skip to main content

bamboo_engine/session_app/
skill_input.rs

1//! Pure ordinary-input preparation. This is not a pre-append authorizer.
2//! A future host must independently resolve current authority and supply the
3//! correlated borrowed data. Nothing here loads, pins, persists or invokes it.
4
5use bamboo_agent_core::{Message, Role};
6use bamboo_domain::MessagePart;
7use bamboo_skills::progressive::{
8    truncate_skill_utf8_bytes, CurrentSkillInput, EXPLICIT_SKILL_PROMPT_BYTES,
9};
10use bamboo_skills::{SkillActivationSnapshot, WorkflowKind, WorkflowSelection, WorkflowStatus};
11use serde_json::Value;
12use std::collections::BTreeSet;
13use std::io::Write;
14
15pub const MAX_EXPLICIT_SKILL_ARGUMENT_BYTES: usize = 8_192;
16pub const MAX_EXPLICIT_SKILLS: usize = 32;
17
18/// Caller-owned current-input restrictions, deliberately not serde or a grant.
19pub struct SkillInputRestrictions<'a> {
20    pub input_id: &'a str,
21    pub ceiling: Option<&'a BTreeSet<String>>,
22    pub disabled: &'a BTreeSet<String>,
23    pub root_ultra: bool,
24    pub mode: Option<&'a str>,
25}
26
27/// Already host-correlated selection and immutable data; a catalog/reader
28/// snapshot alone does not establish permission to construct this input.
29pub struct ChosenSkillInput<'a> {
30    pub selection: &'a WorkflowSelection,
31    pub snapshot: &'a SkillActivationSnapshot,
32    pub main_resource: &'a str,
33}
34
35pub struct SkillInputIntent<'a> {
36    pub input_id: &'a str,
37    pub chosen: &'a [ChosenSkillInput<'a>],
38}
39
40#[derive(Debug)]
41pub struct PreparedSkillInput {
42    pub message: Message,
43    pub warnings: Vec<String>,
44}
45
46struct BoundedJson(Vec<u8>);
47impl Write for BoundedJson {
48    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
49        if self.0.len().saturating_add(bytes.len()) > MAX_EXPLICIT_SKILL_ARGUMENT_BYTES {
50            return Err(std::io::Error::other("Skill arguments exceed byte limit"));
51        }
52        self.0.extend_from_slice(bytes);
53        Ok(bytes.len())
54    }
55    fn flush(&mut self) -> std::io::Result<()> {
56        Ok(())
57    }
58}
59
60/// Prepare ordinary content without modifying the input or any Session.
61/// No intent is a no-op even when no caller could be resolved. Requested
62/// preparation requires a known caller and rejects the whole selection on error.
63pub fn prepare_skill_input(
64    user: &Message,
65    caller: Result<&SkillInputRestrictions<'_>, &str>,
66    intent: Option<&SkillInputIntent<'_>>,
67) -> Result<PreparedSkillInput, String> {
68    let Some(intent) = intent else {
69        return prepare_current_skill_input(user, caller, None);
70    };
71    if intent.chosen.len() > MAX_EXPLICIT_SKILLS {
72        return Err("Skill invocation is empty, excessive or denied by Root Ultra".into());
73    }
74    let chosen = intent
75        .chosen
76        .iter()
77        .map(|chosen| {
78            let entry = chosen
79                .snapshot
80                .skills
81                .get(&chosen.selection.id)
82                .ok_or("chosen Skill is missing from its correlated snapshot")?;
83            Ok(CurrentSkillInput {
84                selection: chosen.selection,
85                definition: &entry.definition,
86                catalog_entry: &entry.catalog_entry,
87                revision: entry.revision,
88                catalog_revision: chosen.snapshot.catalog_revision,
89                mode: chosen.snapshot.selected_skill_mode.as_deref(),
90                main_resource: chosen.main_resource,
91            })
92        })
93        .collect::<Result<Vec<_>, String>>()?;
94    prepare_current_skill_input(user, caller, Some((intent.input_id, &chosen)))
95}
96
97/// The same pure validation/render kernel for borrowed current-publication data.
98/// A host must independently establish current Source and caller authority.
99pub fn prepare_current_skill_input(
100    user: &Message,
101    caller: Result<&SkillInputRestrictions<'_>, &str>,
102    intent: Option<(&str, &[CurrentSkillInput<'_>])>,
103) -> Result<PreparedSkillInput, String> {
104    if user.role != Role::User {
105        return Err("Skill input requires an ordinary User message".into());
106    }
107    let Some((input_id, chosen)) = intent else {
108        return Ok(PreparedSkillInput {
109            message: user.clone(),
110            warnings: vec![],
111        });
112    };
113    let caller = caller.map_err(|error| format!("Skill caller unavailable: {error}"))?;
114    if user.id.is_empty() || user.id != input_id || user.id != caller.input_id {
115        return Err("Skill invocation does not belong to this User input".into());
116    }
117    if chosen.is_empty() || chosen.len() > MAX_EXPLICIT_SKILLS || caller.root_ultra {
118        return Err("Skill invocation is empty, excessive or denied by Root Ultra".into());
119    }
120    let mut ids = BTreeSet::new();
121    let mut fragments = String::new();
122    let mut warnings = Vec::new();
123    for chosen in chosen {
124        let selection = chosen.selection;
125        let catalog = chosen.catalog_entry;
126        if selection.id.is_empty()
127            || selection.id.len() > 256
128            || !ids.insert(&selection.id)
129            || caller
130                .ceiling
131                .is_some_and(|ids| !ids.contains(&selection.id))
132            || caller.disabled.contains(&selection.id)
133            || chosen.catalog_revision == 0
134            || chosen.mode != caller.mode
135            || chosen.definition.id != selection.id
136            || catalog.id != selection.id
137            || chosen.revision == 0
138            || chosen.revision != selection.revision
139            || catalog.revision != selection.revision
140            || catalog.source != selection.source
141            || catalog.kind != WorkflowKind::Instruction
142            || catalog.status != WorkflowStatus::Valid
143            || !catalog.winner
144            || catalog
145                .invocation_policy
146                .get("explicit")
147                .and_then(Value::as_bool)
148                != Some(true)
149        {
150            return Err("chosen Skill identity, mode or explicit restrictions do not match".into());
151        }
152        let mut args = BoundedJson(Vec::with_capacity(MAX_EXPLICIT_SKILL_ARGUMENT_BYTES));
153        serde_json::to_writer(&mut args, &selection.args).map_err(|error| error.to_string())?;
154        bamboo_domain::validate_schema(&catalog.argument_schema, &selection.args)?;
155        let args = String::from_utf8(args.0).map_err(|error| error.to_string())?;
156        let (name, name_cut) = truncate_skill_utf8_bytes(&chosen.definition.name, 256);
157        let (path, path_cut) = truncate_skill_utf8_bytes(chosen.main_resource, 1_024);
158        let (body, body_cut) =
159            truncate_skill_utf8_bytes(&chosen.definition.prompt, EXPLICIT_SKILL_PROMPT_BYTES);
160        if name.is_empty() || path.is_empty() {
161            return Err("chosen Skill requires a name and main-resource locator".into());
162        }
163        // JSON quoting bounds escaping overhead and keeps embedded newlines in
164        // names/paths out of the header structure. Arguments are never truncated.
165        fragments.push_str(&format!(
166            "\n\n### Explicit Skill {}\nfile: {}\narguments: {}\n\n{}",
167            serde_json::to_string(name).map_err(|error| error.to_string())?,
168            serde_json::to_string(path).map_err(|error| error.to_string())?,
169            args,
170            body
171        ));
172        if name_cut || path_cut || body_cut {
173            let warning = format!("Skill {} was truncated (name: {name_cut}, path: {path_cut}, body: {body_cut}; body limit: 8000 UTF-8 bytes).", selection.id);
174            fragments.push_str(&format!("\n\n[Warning: {warning}]"));
175            warnings.push(warning);
176        }
177    }
178    let mut message = user.clone();
179    message.content.push_str(&fragments);
180    if let Some(parts) = &mut message.content_parts {
181        let matching = parts
182            .iter()
183            .filter(|part| matches!(part, MessagePart::Text { text } if text == &user.content))
184            .count();
185        if matching != 1 {
186            return Err("ordinary text parts lack one matching canonical User text".into());
187        }
188        for part in parts {
189            if let MessagePart::Text { text } = part {
190                if text == &user.content {
191                    text.push_str(&fragments);
192                }
193            }
194        }
195    }
196    Ok(PreparedSkillInput { message, warnings })
197}
198
199#[cfg(test)]
200pub(crate) mod tests {
201    use super::*;
202    use bamboo_domain::ImageUrlRef;
203    use bamboo_skills::{
204        SkillActivationSnapshotEntry, SkillDefinition, WorkflowCatalogEntry, WorkflowSource,
205    };
206    use serde_json::json;
207    use std::collections::BTreeMap;
208
209    pub(crate) fn snapshot() -> SkillActivationSnapshot {
210        let definition =
211            SkillDefinition::new("review", "Review", "Review code", "PRIVATE Instructions");
212        let catalog_entry = WorkflowCatalogEntry {
213            id: "review".into(),
214            name: "Review".into(),
215            description: "Review code".into(),
216            kind: WorkflowKind::Instruction,
217            source: WorkflowSource::Workspace,
218            revision: 7,
219            content_digest: "historical".into(),
220            version: "1".into(),
221            invocation_policy: json!({"explicit":true,"automatic":false}),
222            argument_schema: json!({"type":"object","properties":{"target":{"type":"string"}},"required":["target"],"additionalProperties":false}),
223            status: WorkflowStatus::Valid,
224            legacy: false,
225            migration_status: None,
226            last_error: None,
227            winner: true,
228            shadowed_candidates: vec![],
229        };
230        SkillActivationSnapshot {
231            catalog_revision: 9,
232            selected_skill_mode: Some("code".into()),
233            skills: BTreeMap::from([(
234                "review".into(),
235                SkillActivationSnapshotEntry {
236                    definition,
237                    catalog_entry,
238                    revision: 7,
239                    resources: BTreeMap::from([(
240                        "references/private.txt".into(),
241                        b"RESOURCE_MUST_NOT_APPEAR".to_vec(),
242                    )]),
243                },
244            )]),
245        }
246    }
247
248    pub(crate) fn selection() -> WorkflowSelection {
249        WorkflowSelection {
250            id: "review".into(),
251            source: WorkflowSource::Workspace,
252            revision: 7,
253            args: json!({"target":"src/main.rs"}),
254        }
255    }
256
257    fn restrictions<'a>(
258        user: &'a Message,
259        disabled: &'a BTreeSet<String>,
260    ) -> SkillInputRestrictions<'a> {
261        SkillInputRestrictions {
262            input_id: &user.id,
263            ceiling: None,
264            disabled,
265            root_ultra: false,
266            mode: Some("code"),
267        }
268    }
269
270    #[test]
271    fn skill_input_rejects_oversized_intent_before_snapshot_lookup() {
272        let user = Message::user("original");
273        let original = serde_json::to_value(&user).unwrap();
274        let disabled = BTreeSet::new();
275        let caller = restrictions(&user, &disabled);
276        let mut snapshot = snapshot();
277        snapshot.skills.clear();
278        let selection = selection();
279        for (count, expected) in [
280            (
281                MAX_EXPLICIT_SKILLS,
282                "chosen Skill is missing from its correlated snapshot",
283            ),
284            (
285                MAX_EXPLICIT_SKILLS + 1,
286                "Skill invocation is empty, excessive or denied by Root Ultra",
287            ),
288        ] {
289            let chosen = (0..count)
290                .map(|_| ChosenSkillInput {
291                    selection: &selection,
292                    snapshot: &snapshot,
293                    main_resource: "review/SKILL.md",
294                })
295                .collect::<Vec<_>>();
296            let intent = SkillInputIntent {
297                input_id: &user.id,
298                chosen: &chosen,
299            };
300            assert_eq!(
301                prepare_skill_input(&user, Ok(&caller), Some(&intent)).unwrap_err(),
302                expected,
303                "count {count}"
304            );
305            assert_eq!(serde_json::to_value(&user).unwrap(), original);
306        }
307    }
308
309    #[test]
310    fn current_skill_input_reuses_exact_snapshot_kernel_without_auxiliary_data() {
311        let user = Message::user("client ### Explicit Skill text");
312        let mut snapshot = snapshot();
313        snapshot.skills.get_mut("review").unwrap().definition.prompt = "é".repeat(4_001);
314        let selection = selection();
315        let disabled = BTreeSet::new();
316        let caller = restrictions(&user, &disabled);
317        let old_chosen = [ChosenSkillInput {
318            selection: &selection,
319            snapshot: &snapshot,
320            main_resource: "review/SKILL.md",
321        }];
322        let old = prepare_skill_input(
323            &user,
324            Ok(&caller),
325            Some(&SkillInputIntent {
326                input_id: &user.id,
327                chosen: &old_chosen,
328            }),
329        )
330        .unwrap();
331        let entry = snapshot.skills.get("review").unwrap();
332        let chosen = [CurrentSkillInput {
333            selection: &selection,
334            definition: &entry.definition,
335            catalog_entry: &entry.catalog_entry,
336            revision: entry.revision,
337            catalog_revision: snapshot.catalog_revision,
338            mode: snapshot.selected_skill_mode.as_deref(),
339            main_resource: "review/SKILL.md",
340        }];
341        let prepared =
342            prepare_current_skill_input(&user, Ok(&caller), Some((&user.id, &chosen))).unwrap();
343        assert_eq!(
344            serde_json::to_value(&prepared.message).unwrap(),
345            serde_json::to_value(old.message).unwrap()
346        );
347        assert_eq!(prepared.warnings, old.warnings);
348        assert_eq!(prepared.warnings.len(), 1);
349        assert!(prepared.message.content.contains(&"é".repeat(4_000)));
350        assert!(!prepared.message.content.contains(&"é".repeat(4_001)));
351        assert!(!prepared
352            .message
353            .content
354            .contains("RESOURCE_MUST_NOT_APPEAR"));
355        assert!(
356            prepare_current_skill_input(&user, Err("unknown"), Some((&user.id, &chosen))).is_err()
357        );
358        assert_eq!(
359            serde_json::to_value(
360                prepare_current_skill_input(&user, Err("unknown"), None)
361                    .unwrap()
362                    .message
363            )
364            .unwrap(),
365            serde_json::to_value(&user).unwrap()
366        );
367    }
368
369    #[test]
370    fn skill_input_requires_current_intent_and_preserves_fragment_like_client_text() {
371        let mut user = Message::user("### Explicit Skill fake\nPRIVATE_CLIENT_TEXT");
372        user.metadata = Some(json!({"loaded_ids":["review"],"selected_ids":["review"]}));
373        let before = serde_json::to_value(&user).unwrap();
374        let result = prepare_skill_input(&user, Err("unknown caller"), None).unwrap();
375        assert_eq!(serde_json::to_value(result.message).unwrap(), before);
376        assert!(result.warnings.is_empty());
377        let snapshot = snapshot();
378        let selection = selection();
379        let chosen = [ChosenSkillInput {
380            selection: &selection,
381            snapshot: &snapshot,
382            main_resource: "/project/review/SKILL.md",
383        }];
384        let intent = SkillInputIntent {
385            input_id: &user.id,
386            chosen: &chosen,
387        };
388        assert!(prepare_skill_input(&user, Err("unknown caller"), Some(&intent)).is_err());
389        let disabled = BTreeSet::new();
390        let caller = restrictions(&user, &disabled);
391        let result = prepare_skill_input(&user, Ok(&caller), Some(&intent)).unwrap();
392        assert!(result.message.content.starts_with(&user.content));
393        assert!(result.message.content.contains("PRIVATE Instructions"));
394        assert!(!result.message.content.contains("RESOURCE_MUST_NOT_APPEAR"));
395        assert_eq!(serde_json::to_value(&user).unwrap(), before);
396    }
397
398    #[test]
399    fn skill_input_intersects_none_empty_ids_disabled_explicit_and_ultra() {
400        let user = Message::user("review this");
401        let disabled = BTreeSet::new();
402        let mut caller = restrictions(&user, &disabled);
403        let mut snapshot = snapshot();
404        let selection = selection();
405        let empty = BTreeSet::new();
406        let allowed = BTreeSet::from(["review".into()]);
407        for (ceiling, expected) in [(None, true), (Some(&empty), false), (Some(&allowed), true)] {
408            caller.ceiling = ceiling;
409            let chosen = [ChosenSkillInput {
410                selection: &selection,
411                snapshot: &snapshot,
412                main_resource: "review/SKILL.md",
413            }];
414            let intent = SkillInputIntent {
415                input_id: &user.id,
416                chosen: &chosen,
417            };
418            assert_eq!(
419                prepare_skill_input(&user, Ok(&caller), Some(&intent)).is_ok(),
420                expected
421            );
422        }
423        caller.ceiling = None;
424        for policy in [
425            json!({"explicit":false,"automatic":true}),
426            json!({"explicit":"true"}),
427            json!({}),
428        ] {
429            snapshot
430                .skills
431                .get_mut("review")
432                .unwrap()
433                .catalog_entry
434                .invocation_policy = policy;
435            let chosen = [ChosenSkillInput {
436                selection: &selection,
437                snapshot: &snapshot,
438                main_resource: "review/SKILL.md",
439            }];
440            assert!(prepare_skill_input(
441                &user,
442                Ok(&caller),
443                Some(&SkillInputIntent {
444                    input_id: &user.id,
445                    chosen: &chosen
446                })
447            )
448            .is_err());
449        }
450        snapshot = self::snapshot();
451        let chosen = [ChosenSkillInput {
452            selection: &selection,
453            snapshot: &snapshot,
454            main_resource: "review/SKILL.md",
455        }];
456        let intent = SkillInputIntent {
457            input_id: &user.id,
458            chosen: &chosen,
459        };
460        caller.root_ultra = true;
461        assert!(prepare_skill_input(&user, Ok(&caller), Some(&intent)).is_err());
462        caller.root_ultra = false;
463        let disabled = BTreeSet::from(["review".into()]);
464        caller.disabled = &disabled;
465        assert!(prepare_skill_input(&user, Ok(&caller), Some(&intent)).is_err());
466    }
467
468    #[test]
469    fn skill_input_rejects_stale_identity_mode_and_schema_atomically() {
470        let user = Message::user("original");
471        let disabled = BTreeSet::new();
472        let caller = restrictions(&user, &disabled);
473        let original = serde_json::to_value(&user).unwrap();
474        for variant in 0..9 {
475            let mut snapshot = snapshot();
476            let mut selection = selection();
477            match variant {
478                0 => selection.source = WorkflowSource::Builtin,
479                1 => selection.revision += 1,
480                2 => selection.args = json!({"target":13}),
481                3 => snapshot.selected_skill_mode = None,
482                4 => snapshot.skills.get_mut("review").unwrap().revision += 1,
483                5 => {
484                    snapshot
485                        .skills
486                        .get_mut("review")
487                        .unwrap()
488                        .catalog_entry
489                        .status = WorkflowStatus::Invalid
490                }
491                6 => {
492                    snapshot
493                        .skills
494                        .get_mut("review")
495                        .unwrap()
496                        .catalog_entry
497                        .kind = WorkflowKind::Orchestration
498                }
499                7 => snapshot.skills.get_mut("review").unwrap().definition.id = "foreign".into(),
500                _ => snapshot.catalog_revision = 0,
501            }
502            let chosen = [ChosenSkillInput {
503                selection: &selection,
504                snapshot: &snapshot,
505                main_resource: "review/SKILL.md",
506            }];
507            assert!(
508                prepare_skill_input(
509                    &user,
510                    Ok(&caller),
511                    Some(&SkillInputIntent {
512                        input_id: &user.id,
513                        chosen: &chosen
514                    })
515                )
516                .is_err(),
517                "variant {variant}"
518            );
519            assert_eq!(serde_json::to_value(&user).unwrap(), original);
520        }
521        let snapshot = snapshot();
522        let selection = selection();
523        let chosen = [ChosenSkillInput {
524            selection: &selection,
525            snapshot: &snapshot,
526            main_resource: "review/SKILL.md",
527        }];
528        assert!(prepare_skill_input(
529            &user,
530            Ok(&caller),
531            Some(&SkillInputIntent {
532                input_id: "old-input",
533                chosen: &chosen
534            })
535        )
536        .is_err());
537        assert!(
538            prepare_skill_input(&Message::assistant("wrong role", None), Ok(&caller), None)
539                .is_err()
540        );
541    }
542
543    #[test]
544    fn skill_input_preserves_native_images_other_parts_identity_and_time() {
545        let image = MessagePart::ImageUrl {
546            image_url: ImageUrlRef {
547                url: "data:image/png;base64,AA==".into(),
548                detail: Some("high".into()),
549            },
550        };
551        let mut user = Message::user_with_parts(
552            "original",
553            vec![
554                MessagePart::Text {
555                    text: "original".into(),
556                },
557                image.clone(),
558                MessagePart::Text {
559                    text: "additional existing part".into(),
560                },
561            ],
562        );
563        user.metadata = Some(json!({"client":"unchanged"}));
564        let snapshot = snapshot();
565        let selection = selection();
566        let disabled = BTreeSet::new();
567        let caller = restrictions(&user, &disabled);
568        let chosen = [ChosenSkillInput {
569            selection: &selection,
570            snapshot: &snapshot,
571            main_resource: "review/SKILL.md",
572        }];
573        let result = prepare_skill_input(
574            &user,
575            Ok(&caller),
576            Some(&SkillInputIntent {
577                input_id: &user.id,
578                chosen: &chosen,
579            }),
580        )
581        .unwrap()
582        .message;
583        assert_eq!(result.id, user.id);
584        assert_eq!(result.created_at, user.created_at);
585        assert_eq!(result.role, Role::User);
586        assert_eq!(result.metadata, user.metadata);
587        let parts = result.content_parts.unwrap();
588        assert_eq!(
589            parts[0],
590            MessagePart::Text {
591                text: result.content
592            }
593        );
594        assert_eq!(parts[1], image);
595        assert_eq!(parts[2], user.content_parts.as_ref().unwrap()[2]);
596        user.content_parts = Some(vec![image]);
597        assert!(prepare_skill_input(
598            &user,
599            Ok(&restrictions(&user, &disabled)),
600            Some(&SkillInputIntent {
601                input_id: &user.id,
602                chosen: &chosen
603            })
604        )
605        .is_err());
606    }
607
608    #[test]
609    fn skill_input_bounds_utf8_headers_body_and_arguments_without_dropping_values() {
610        let user = Message::user("original user text longer than 8000: ".repeat(300));
611        let disabled = BTreeSet::new();
612        let caller = restrictions(&user, &disabled);
613        let mut snapshot = snapshot();
614        let mut selection = selection();
615        snapshot.skills.get_mut("review").unwrap().definition.name = "名\n".repeat(100);
616        snapshot.skills.get_mut("review").unwrap().definition.prompt =
617            format!("{}END", "🙂".repeat(2_000));
618        selection.args = json!({"target":"ARGUMENT_VALUE"});
619        let path = "路\n".repeat(400);
620        let chosen = [ChosenSkillInput {
621            selection: &selection,
622            snapshot: &snapshot,
623            main_resource: &path,
624        }];
625        let result = prepare_skill_input(
626            &user,
627            Ok(&caller),
628            Some(&SkillInputIntent {
629                input_id: &user.id,
630                chosen: &chosen,
631            }),
632        )
633        .unwrap();
634        assert!(result.message.content.starts_with(&user.content));
635        assert!(result.message.content.contains("ARGUMENT_VALUE"));
636        assert!(!result.message.content.contains("END"));
637        assert_eq!(result.message.content.matches('🙂').count(), 2_000);
638        assert_eq!(result.warnings.len(), 1);
639        assert!(result.message.content.contains("[Warning:"));
640        assert!(result.message.content.contains("8000 UTF-8 bytes"));
641        selection.args = json!({"target":"x".repeat(MAX_EXPLICIT_SKILL_ARGUMENT_BYTES)});
642        let chosen = [ChosenSkillInput {
643            selection: &selection,
644            snapshot: &snapshot,
645            main_resource: "review/SKILL.md",
646        }];
647        assert!(prepare_skill_input(
648            &user,
649            Ok(&caller),
650            Some(&SkillInputIntent {
651                input_id: &user.id,
652                chosen: &chosen
653            })
654        )
655        .is_err());
656    }
657
658    #[test]
659    fn skill_input_multiple_choices_are_atomic_ordered_and_bounded() {
660        let user = Message::user("original");
661        let disabled = BTreeSet::new();
662        let caller = restrictions(&user, &disabled);
663        let first = snapshot();
664        let one = selection();
665        let mut second = snapshot();
666        let mut two = selection();
667        two.id = "other".into();
668        let mut entry = second.skills.remove("review").unwrap();
669        entry.definition.id = two.id.clone();
670        entry.definition.prompt = "SECOND Instructions".into();
671        entry.catalog_entry.id = two.id.clone();
672        second.skills.insert(two.id.clone(), entry);
673        let chosen = [
674            ChosenSkillInput {
675                selection: &one,
676                snapshot: &first,
677                main_resource: "one/SKILL.md",
678            },
679            ChosenSkillInput {
680                selection: &two,
681                snapshot: &second,
682                main_resource: "two/SKILL.md",
683            },
684        ];
685        let result = prepare_skill_input(
686            &user,
687            Ok(&caller),
688            Some(&SkillInputIntent {
689                input_id: &user.id,
690                chosen: &chosen,
691            }),
692        )
693        .unwrap();
694        assert!(
695            result.message.content.find("PRIVATE Instructions").unwrap()
696                < result.message.content.find("SECOND Instructions").unwrap()
697        );
698        two.revision += 1;
699        let chosen = [
700            ChosenSkillInput {
701                selection: &one,
702                snapshot: &first,
703                main_resource: "one/SKILL.md",
704            },
705            ChosenSkillInput {
706                selection: &two,
707                snapshot: &second,
708                main_resource: "two/SKILL.md",
709            },
710        ];
711        assert!(prepare_skill_input(
712            &user,
713            Ok(&caller),
714            Some(&SkillInputIntent {
715                input_id: &user.id,
716                chosen: &chosen
717            })
718        )
719        .is_err());
720        assert_eq!(user.content, "original");
721        let chosen = (0..MAX_EXPLICIT_SKILLS + 1)
722            .map(|_| ChosenSkillInput {
723                selection: &one,
724                snapshot: &first,
725                main_resource: "one/SKILL.md",
726            })
727            .collect::<Vec<_>>();
728        assert!(prepare_skill_input(
729            &user,
730            Ok(&caller),
731            Some(&SkillInputIntent {
732                input_id: &user.id,
733                chosen: &chosen
734            })
735        )
736        .is_err());
737    }
738}