1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use serde::Deserialize;
7use serde_json::json;
8use tokio::sync::RwLock;
9
10use bamboo_engine::access_control::{self, SkillAccessError, SkillSessionPort};
11use bamboo_engine::resource_helpers::{
12 display_relative_path, list_skill_resource_paths, normalize_relative_resource_path,
13 page_text_lines, truncate_text,
14};
15use bamboo_engine::runtime_metadata::LAST_RESOURCE_READ_SUMMARY_METADATA_KEY;
16use bamboo_engine::SkillManager;
17use bamboo_infrastructure::Config;
18
19use bamboo_agent_core::storage::Storage;
20use bamboo_agent_core::tools::{Tool, ToolError, ToolExecutionContext, ToolResult};
21use bamboo_agent_core::Session;
22use bamboo_infrastructure::LockedSessionStore;
23
24const MAX_RESOURCE_CONTENT_CHARS: usize = 50_000;
25
26#[derive(Clone)]
27struct SkillToolAccess {
28 skill_manager: Arc<SkillManager>,
29 config: Arc<RwLock<Config>>,
30 sessions: Arc<RwLock<HashMap<String, Session>>>,
31 storage: Arc<dyn Storage>,
32 persistence: Arc<LockedSessionStore>,
33}
34
35impl SkillToolAccess {
36 fn new(
37 skill_manager: Arc<SkillManager>,
38 config: Arc<RwLock<Config>>,
39 sessions: Arc<RwLock<HashMap<String, Session>>>,
40 storage: Arc<dyn Storage>,
41 persistence: Arc<LockedSessionStore>,
42 ) -> Self {
43 Self {
44 skill_manager,
45 config,
46 sessions,
47 storage,
48 persistence,
49 }
50 }
51
52 async fn session_for_context(&self, session_id: Option<&str>) -> Option<Session> {
53 let session_id = session_id?;
54
55 let in_memory = {
56 let sessions = self.sessions.read().await;
57 sessions.get(session_id).cloned()
58 };
59
60 match in_memory {
61 Some(session) => Some(session),
62 None => self.storage.load_session(session_id).await.ok().flatten(),
63 }
64 }
65
66 async fn skill_root(
67 &self,
68 skill_id: &str,
69 skill_mode: Option<&str>,
70 ) -> Result<PathBuf, ToolError> {
71 self.skill_manager
72 .store()
73 .get_skill_root_for_mode(skill_id, skill_mode)
74 .await
75 .map_err(|err| ToolError::Execution(format!("Failed to resolve skill root: {err}")))
76 }
77}
78
79#[async_trait]
80impl SkillSessionPort for SkillToolAccess {
81 async fn load_session_metadata(&self, session_id: &str) -> Option<HashMap<String, String>> {
82 self.session_for_context(Some(session_id))
83 .await
84 .map(|session| session.metadata.clone())
85 }
86
87 async fn save_metadata_updates(
88 &self,
89 session_id: &str,
90 updates: &[(String, Option<String>)],
91 ) -> Result<(), String> {
92 let mut session = {
93 let sessions = self.sessions.read().await;
94 sessions.get(session_id).cloned()
95 };
96
97 if session.is_none() {
98 session = self
99 .storage
100 .load_session(session_id)
101 .await
102 .map_err(|e| e.to_string())?;
103 }
104
105 let mut session = session.ok_or_else(|| format!("Session '{session_id}' not found"))?;
106
107 for (key, value) in updates {
108 if let Some(val) = value {
109 session.metadata.insert(key.clone(), val.clone());
110 } else {
111 session.metadata.remove(key);
112 }
113 }
114
115 self.persistence
116 .merge_save_runtime(&mut session)
117 .await
118 .map_err(|e| e.to_string())?;
119
120 let mut sessions = self.sessions.write().await;
121 sessions.insert(session_id.to_string(), session);
122
123 Ok(())
124 }
125
126 async fn disabled_skill_ids(&self) -> HashSet<String> {
127 let config = self.config.read().await;
128 config.disabled_skill_ids().into_iter().collect()
129 }
130}
131
132fn skill_access_error_to_tool_error(error: SkillAccessError) -> ToolError {
133 match error {
134 SkillAccessError::NotAllowed(msg)
135 | SkillAccessError::NotLoaded(msg)
136 | SkillAccessError::SessionRequired(msg)
137 | SkillAccessError::SessionNotFound(msg)
138 | SkillAccessError::PersistenceError(msg) => ToolError::Execution(msg),
139 }
140}
141
142#[derive(Debug, Deserialize)]
143struct LoadSkillArgs {
144 skill_id: String,
145}
146
147pub struct LoadSkillTool {
148 access: SkillToolAccess,
149}
150
151impl LoadSkillTool {
152 pub fn new(
153 skill_manager: Arc<SkillManager>,
154 config: Arc<RwLock<Config>>,
155 sessions: Arc<RwLock<HashMap<String, Session>>>,
156 storage: Arc<dyn Storage>,
157 persistence: Arc<LockedSessionStore>,
158 ) -> Self {
159 Self {
160 access: SkillToolAccess::new(skill_manager, config, sessions, storage, persistence),
161 }
162 }
163}
164
165#[async_trait]
166impl Tool for LoadSkillTool {
167 fn name(&self) -> &str {
168 "load_skill"
169 }
170
171 fn description(&self) -> &str {
172 "Load a skill's detailed SKILL.md instructions by skill_id."
173 }
174
175 fn parameters_schema(&self) -> serde_json::Value {
176 json!({
177 "type": "object",
178 "properties": {
179 "skill_id": {
180 "type": "string",
181 "description": "Skill ID from the advertised skill list (for example: skill-creator)."
182 }
183 },
184 "required": ["skill_id"]
185 })
186 }
187
188 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
189 self.execute_with_context(args, ToolExecutionContext::none("tool_call"))
190 .await
191 }
192
193 async fn execute_with_context(
194 &self,
195 args: serde_json::Value,
196 ctx: ToolExecutionContext<'_>,
197 ) -> Result<ToolResult, ToolError> {
198 let parsed: LoadSkillArgs = serde_json::from_value(args).map_err(|err| {
199 ToolError::InvalidArguments(format!("Invalid load_skill args: {err}"))
200 })?;
201 let skill_id = parsed.skill_id.trim();
202 if skill_id.is_empty() {
203 return Err(ToolError::InvalidArguments(
204 "skill_id must be a non-empty string".to_string(),
205 ));
206 }
207
208 access_control::ensure_skill_allowed(&self.access, skill_id, ctx.session_id)
209 .await
210 .map_err(skill_access_error_to_tool_error)?;
211 let skill_mode = access_control::selected_skill_mode(&self.access, ctx.session_id).await;
212
213 let skill = self
214 .access
215 .skill_manager
216 .store()
217 .get_skill_for_mode(skill_id, skill_mode.as_deref())
218 .await
219 .map_err(|err| {
220 ToolError::Execution(format!("Failed to load skill '{skill_id}': {err}"))
221 })?;
222 let skill_root = self
223 .access
224 .skill_root(skill_id, skill_mode.as_deref())
225 .await?;
226 let resources = list_skill_resource_paths(&skill_root).map_err(|err| {
227 ToolError::Execution(format!("Failed to list skill resources: {err}"))
228 })?;
229 let canonical_skill_root = tokio::fs::canonicalize(&skill_root)
230 .await
231 .unwrap_or(skill_root);
232 access_control::mark_skill_loaded(&self.access, skill_id, ctx.session_id)
233 .await
234 .map_err(skill_access_error_to_tool_error)?;
235
236 Ok(ToolResult {
237 success: true,
238 result: json!({
239 "skill_id": skill.id,
240 "name": skill.name,
241 "description": skill.description,
242 "license": skill.license,
243 "compatibility": skill.compatibility,
244 "allowed_tools": skill.tool_refs,
245 "instructions": skill.prompt,
246 "skill_base_dir": bamboo_infrastructure::paths::path_to_display_string(&canonical_skill_root),
247 "resource_files": resources
248 })
249 .to_string(),
250 display_preference: Some("Collapsible".to_string()),
251 })
252 }
253}
254
255#[derive(Debug, Deserialize)]
256struct ReadSkillResourceArgs {
257 skill_id: String,
258 resource_path: String,
259 #[serde(default)]
260 offset: Option<usize>,
261 #[serde(default)]
262 limit: Option<usize>,
263}
264
265pub struct ReadSkillResourceTool {
266 access: SkillToolAccess,
267}
268
269impl ReadSkillResourceTool {
270 pub fn new(
271 skill_manager: Arc<SkillManager>,
272 config: Arc<RwLock<Config>>,
273 sessions: Arc<RwLock<HashMap<String, Session>>>,
274 storage: Arc<dyn Storage>,
275 persistence: Arc<LockedSessionStore>,
276 ) -> Self {
277 Self {
278 access: SkillToolAccess::new(skill_manager, config, sessions, storage, persistence),
279 }
280 }
281}
282
283#[async_trait]
284impl Tool for ReadSkillResourceTool {
285 fn name(&self) -> &str {
286 "read_skill_resource"
287 }
288
289 fn description(&self) -> &str {
290 "Read a resource file under a skill directory by relative resource_path."
291 }
292
293 fn parameters_schema(&self) -> serde_json::Value {
294 json!({
295 "type": "object",
296 "properties": {
297 "skill_id": {
298 "type": "string",
299 "description": "Skill ID that owns the resource."
300 },
301 "resource_path": {
302 "type": "string",
303 "description": "Relative path inside the skill folder (for example: references/policies.md)."
304 },
305 "offset": {
306 "type": "number",
307 "description": "Optional 0-based line offset for paged text reads."
308 },
309 "limit": {
310 "type": "number",
311 "description": "Optional line limit for paged text reads."
312 }
313 },
314 "required": ["skill_id", "resource_path"]
315 })
316 }
317
318 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
319 self.execute_with_context(args, ToolExecutionContext::none("tool_call"))
320 .await
321 }
322
323 async fn execute_with_context(
324 &self,
325 args: serde_json::Value,
326 ctx: ToolExecutionContext<'_>,
327 ) -> Result<ToolResult, ToolError> {
328 let parsed: ReadSkillResourceArgs = serde_json::from_value(args).map_err(|err| {
329 ToolError::InvalidArguments(format!("Invalid read_skill_resource args: {err}"))
330 })?;
331 let skill_id = parsed.skill_id.trim();
332 if skill_id.is_empty() {
333 return Err(ToolError::InvalidArguments(
334 "skill_id must be a non-empty string".to_string(),
335 ));
336 }
337
338 access_control::ensure_skill_allowed(&self.access, skill_id, ctx.session_id)
339 .await
340 .map_err(skill_access_error_to_tool_error)?;
341 access_control::ensure_skill_loaded(&self.access, skill_id, ctx.session_id)
342 .await
343 .map_err(skill_access_error_to_tool_error)?;
344 let skill_mode = access_control::selected_skill_mode(&self.access, ctx.session_id).await;
345
346 let resource_path = normalize_relative_resource_path(&parsed.resource_path)
347 .map_err(ToolError::InvalidArguments)?;
348 if resource_path == Path::new("SKILL.md") {
349 return Err(ToolError::InvalidArguments(
350 "Use load_skill for SKILL.md instructions; read_skill_resource is for auxiliary files"
351 .to_string(),
352 ));
353 }
354
355 let skill_root = self
356 .access
357 .skill_root(skill_id, skill_mode.as_deref())
358 .await?;
359 let canonical_root = tokio::fs::canonicalize(&skill_root).await.map_err(|_| {
360 ToolError::Execution(format!(
361 "Skill directory not found for '{skill_id}'. Load the skill list first."
362 ))
363 })?;
364 let canonical_resource = tokio::fs::canonicalize(skill_root.join(&resource_path))
365 .await
366 .map_err(|_| {
367 ToolError::Execution(format!(
368 "Skill resource not found: {}/{}",
369 skill_id,
370 display_relative_path(&resource_path)
371 ))
372 })?;
373
374 if !canonical_resource.starts_with(&canonical_root) {
375 return Err(ToolError::InvalidArguments(
376 "resource_path must stay inside the skill directory".to_string(),
377 ));
378 }
379
380 let metadata = tokio::fs::metadata(&canonical_resource)
381 .await
382 .map_err(|err| ToolError::Execution(format!("Failed to stat resource: {err}")))?;
383 if !metadata.is_file() {
384 return Err(ToolError::InvalidArguments(format!(
385 "resource_path must reference a file: {}",
386 display_relative_path(&resource_path)
387 )));
388 }
389
390 let bytes = tokio::fs::read(&canonical_resource)
391 .await
392 .map_err(|err| ToolError::Execution(format!("Failed to read skill resource: {err}")))?;
393 let size_bytes = bytes.len();
394
395 let result = match String::from_utf8(bytes) {
396 Ok(text) => {
397 let offset = parsed.offset.unwrap_or(0);
398 let (paged, start, end, total_lines) = page_text_lines(&text, offset, parsed.limit);
399 let (excerpt, truncated) = truncate_text(&paged, MAX_RESOURCE_CONTENT_CHARS);
400 let has_more = end < total_lines;
401 let summary = json!({
402 "skill_id": skill_id,
403 "resource_path": display_relative_path(&resource_path),
404 "offset": start,
405 "limit": parsed.limit,
406 "returned_lines": end.saturating_sub(start),
407 "total_lines": total_lines,
408 "has_more": has_more,
409 "truncated": truncated,
410 "binary": false
411 });
412 if let Some(session_id) = ctx.session_id {
413 if let Some(mut session) =
414 self.access.session_for_context(Some(session_id)).await
415 {
416 session.metadata.insert(
417 LAST_RESOURCE_READ_SUMMARY_METADATA_KEY.to_string(),
418 summary.to_string(),
419 );
420 let _ = self
421 .access
422 .persistence
423 .merge_save_runtime(&mut session)
424 .await;
425 let mut sessions = self.access.sessions.write().await;
426 sessions.insert(session_id.to_string(), session);
427 }
428 }
429 json!({
430 "skill_id": skill_id,
431 "resource_path": display_relative_path(&resource_path),
432 "size_bytes": size_bytes,
433 "offset": start,
434 "limit": parsed.limit,
435 "returned_lines": end.saturating_sub(start),
436 "total_lines": total_lines,
437 "has_more": has_more,
438 "next_offset": if has_more { Some(end) } else { None::<usize> },
439 "truncated": truncated,
440 "content": excerpt
441 })
442 }
443 Err(_) => json!({
444 "skill_id": skill_id,
445 "resource_path": display_relative_path(&resource_path),
446 "size_bytes": size_bytes,
447 "binary": true,
448 "message": "Resource is not UTF-8 text. Use file tools when binary handling is required."
449 }),
450 };
451
452 Ok(ToolResult {
453 success: true,
454 result: result.to_string(),
455 display_preference: Some("Collapsible".to_string()),
456 })
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::{LoadSkillTool, ReadSkillResourceTool};
463 use bamboo_engine::access_control::{parse_loaded_skill_ids, serialize_loaded_skill_ids};
464 use bamboo_engine::runtime_metadata::{
465 LAST_LOADED_SKILL_SUMMARY_METADATA_KEY, LAST_RESOURCE_READ_SUMMARY_METADATA_KEY,
466 };
467 use std::collections::{HashMap, HashSet};
468 use std::sync::Arc;
469
470 use tokio::sync::RwLock;
471
472 use bamboo_agent_core::storage::Storage;
473 use bamboo_agent_core::tools::{Tool, ToolExecutionContext};
474 use bamboo_agent_core::Session;
475 use bamboo_engine::{SkillManager, SkillStoreConfig};
476 use bamboo_infrastructure::Config;
477
478 #[test]
479 fn parse_loaded_skill_ids_supports_json_and_csv() {
480 let from_json = parse_loaded_skill_ids(r#"["skill-b","skill-a","skill-a"]"#);
481 assert_eq!(from_json.len(), 2);
482 assert!(from_json.contains("skill-a"));
483 assert!(from_json.contains("skill-b"));
484
485 let from_csv = parse_loaded_skill_ids("skill-c, skill-d , skill-c");
486 assert_eq!(from_csv.len(), 2);
487 assert!(from_csv.contains("skill-c"));
488 assert!(from_csv.contains("skill-d"));
489 }
490
491 #[test]
492 fn serialize_loaded_skill_ids_is_stable_and_sorted() {
493 let mut ids = HashSet::new();
494 ids.insert("skill-b".to_string());
495 ids.insert("skill-a".to_string());
496
497 assert_eq!(serialize_loaded_skill_ids(&ids), r#"["skill-a","skill-b"]"#);
498 }
499
500 #[derive(Default)]
501 struct TestStorage {
502 sessions: RwLock<HashMap<String, Session>>,
503 }
504
505 #[async_trait::async_trait]
506 impl Storage for TestStorage {
507 async fn save_session(&self, session: &Session) -> std::io::Result<()> {
508 self.sessions
509 .write()
510 .await
511 .insert(session.id.clone(), session.clone());
512 Ok(())
513 }
514
515 async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
516 Ok(self.sessions.read().await.get(session_id).cloned())
517 }
518
519 async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
520 Ok(self.sessions.write().await.remove(session_id).is_some())
521 }
522 }
523
524 #[tokio::test]
525 async fn load_skill_rejects_globally_disabled_skill() {
526 let temp_dir = tempfile::tempdir().expect("tempdir should be created");
527 let skill_dir = temp_dir.path().join("skills").join("demo-skill");
528 std::fs::create_dir_all(&skill_dir).expect("skill dir should exist");
529 std::fs::write(
530 skill_dir.join("SKILL.md"),
531 r#"---
532name: demo-skill
533description: Demo description
534---
535Use this demo skill."#,
536 )
537 .expect("skill file should be written");
538
539 let skill_manager = Arc::new(SkillManager::with_config(SkillStoreConfig {
540 skills_dir: temp_dir.path().join("skills"),
541 project_dir: None,
542 active_mode: None,
543 }));
544 skill_manager
545 .initialize()
546 .await
547 .expect("skill manager should initialize");
548
549 let config = Arc::new(RwLock::new(Config::default()));
550 {
551 let mut cfg = config.write().await;
552 cfg.skills.disabled = vec!["demo-skill".to_string()];
553 cfg.normalize_skill_settings();
554 }
555
556 let session_id = "session-1";
557 let session = Session::new(session_id, "model");
558 let sessions = Arc::new(RwLock::new(HashMap::from([(
559 session_id.to_string(),
560 session.clone(),
561 )])));
562 let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
563 storage
564 .save_session(&session)
565 .await
566 .expect("session should be saved");
567
568 let persistence = Arc::new(bamboo_infrastructure::LockedSessionStore::new(
569 storage.clone(),
570 ));
571
572 let tool = LoadSkillTool::new(skill_manager, config, sessions, storage, persistence);
573 let ctx = ToolExecutionContext {
574 session_id: Some(session_id),
575 tool_call_id: "tool-call-1",
576 event_tx: None,
577 available_tool_schemas: None,
578 };
579
580 let error = tool
581 .execute_with_context(serde_json::json!({ "skill_id": "demo-skill" }), ctx)
582 .await
583 .expect_err("disabled skill should be rejected");
584
585 assert!(error
586 .to_string()
587 .contains("globally disabled in Bamboo settings"));
588 }
589
590 #[tokio::test]
591 async fn load_skill_persists_last_loaded_skill_summary() {
592 let temp_dir = tempfile::tempdir().expect("tempdir should be created");
593 let skill_dir = temp_dir.path().join("skills").join("demo-skill");
594 std::fs::create_dir_all(&skill_dir).expect("skill dir should exist");
595 std::fs::write(
596 skill_dir.join("SKILL.md"),
597 r#"---
598name: demo-skill
599description: Demo description
600---
601Use this demo skill."#,
602 )
603 .expect("skill file should be written");
604
605 let skill_manager = Arc::new(SkillManager::with_config(SkillStoreConfig {
606 skills_dir: temp_dir.path().join("skills"),
607 project_dir: None,
608 active_mode: None,
609 }));
610 skill_manager
611 .initialize()
612 .await
613 .expect("skill manager should initialize");
614
615 let config = Arc::new(RwLock::new(Config::default()));
616 let session_id = "session-2";
617 let session = Session::new(session_id, "model");
618 let sessions = Arc::new(RwLock::new(HashMap::from([(
619 session_id.to_string(),
620 session.clone(),
621 )])));
622 let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
623 storage
624 .save_session(&session)
625 .await
626 .expect("session should be saved");
627 let persistence = Arc::new(bamboo_infrastructure::LockedSessionStore::new(
628 storage.clone(),
629 ));
630
631 let tool = LoadSkillTool::new(
632 skill_manager,
633 config,
634 sessions.clone(),
635 storage.clone(),
636 persistence.clone(),
637 );
638 let ctx = ToolExecutionContext {
639 session_id: Some(session_id),
640 tool_call_id: "tool-call-2",
641 event_tx: None,
642 available_tool_schemas: None,
643 };
644
645 let _ = tool
646 .execute_with_context(serde_json::json!({ "skill_id": "demo-skill" }), ctx)
647 .await
648 .expect("load_skill should succeed");
649
650 let saved = storage
651 .load_session(session_id)
652 .await
653 .expect("load session should succeed")
654 .expect("session should exist");
655 let summary = saved
656 .metadata
657 .get(LAST_LOADED_SKILL_SUMMARY_METADATA_KEY)
658 .expect("last loaded skill summary should be present");
659 assert!(summary.contains("demo-skill"));
660 }
661
662 #[tokio::test]
663 async fn read_skill_resource_persists_last_resource_read_summary() {
664 let temp_dir = tempfile::tempdir().expect("tempdir should be created");
665 let skill_dir = temp_dir.path().join("skills").join("demo-skill");
666 let refs_dir = skill_dir.join("references");
667 std::fs::create_dir_all(&refs_dir).expect("references dir should exist");
668 std::fs::write(
669 skill_dir.join("SKILL.md"),
670 r#"---
671name: demo-skill
672description: Demo description
673---
674Use this demo skill."#,
675 )
676 .expect("skill file should be written");
677 std::fs::write(refs_dir.join("policy.md"), "line1\nline2\nline3\n")
678 .expect("resource file should be written");
679
680 let skill_manager = Arc::new(SkillManager::with_config(SkillStoreConfig {
681 skills_dir: temp_dir.path().join("skills"),
682 project_dir: None,
683 active_mode: None,
684 }));
685 skill_manager
686 .initialize()
687 .await
688 .expect("skill manager should initialize");
689
690 let config = Arc::new(RwLock::new(Config::default()));
691 let session_id = "session-3";
692 let session = Session::new(session_id, "model");
693 let sessions = Arc::new(RwLock::new(HashMap::from([(
694 session_id.to_string(),
695 session.clone(),
696 )])));
697 let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
698 storage
699 .save_session(&session)
700 .await
701 .expect("session should be saved");
702 let persistence = Arc::new(bamboo_infrastructure::LockedSessionStore::new(
703 storage.clone(),
704 ));
705
706 let load_tool = LoadSkillTool::new(
707 skill_manager.clone(),
708 config.clone(),
709 sessions.clone(),
710 storage.clone(),
711 persistence.clone(),
712 );
713 let read_tool = ReadSkillResourceTool::new(
714 skill_manager,
715 config,
716 sessions,
717 storage.clone(),
718 persistence,
719 );
720
721 let load_ctx = ToolExecutionContext {
722 session_id: Some(session_id),
723 tool_call_id: "tool-call-load",
724 event_tx: None,
725 available_tool_schemas: None,
726 };
727 let read_ctx = ToolExecutionContext {
728 session_id: Some(session_id),
729 tool_call_id: "tool-call-read",
730 event_tx: None,
731 available_tool_schemas: None,
732 };
733
734 let _ = load_tool
735 .execute_with_context(serde_json::json!({ "skill_id": "demo-skill" }), load_ctx)
736 .await
737 .expect("load_skill should succeed");
738
739 let _ = read_tool
740 .execute_with_context(
741 serde_json::json!({
742 "skill_id": "demo-skill",
743 "resource_path": "references/policy.md",
744 "offset": 1,
745 "limit": 1
746 }),
747 read_ctx,
748 )
749 .await
750 .expect("read_skill_resource should succeed");
751
752 let saved = storage
753 .load_session(session_id)
754 .await
755 .expect("load session should succeed")
756 .expect("session should exist");
757 let summary = saved
758 .metadata
759 .get(LAST_RESOURCE_READ_SUMMARY_METADATA_KEY)
760 .expect("last resource read summary should be present");
761 assert!(summary.contains("demo-skill"));
762 assert!(summary.contains("references/policy.md"));
763 assert!(summary.contains("\"offset\":1"));
764 }
765}