1use 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
18pub 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
27pub 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
60pub 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
97pub 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 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}