chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,688 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox import Manifest, SandboxPathGrant
|
||||
from agents.sandbox.capabilities import LocalDirLazySkillSource, Skill, Skills
|
||||
from agents.sandbox.entries import Dir, File, LocalDir
|
||||
from agents.sandbox.errors import SkillsConfigError
|
||||
from agents.sandbox.files import EntryKind, FileEntry
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.snapshot import NoopSnapshot
|
||||
from agents.sandbox.types import ExecResult, Permissions, User
|
||||
from agents.sandbox.workspace_paths import coerce_posix_path
|
||||
from agents.tool import FunctionTool
|
||||
from agents.tool_context import ToolContext
|
||||
from tests.utils.factories import TestSessionState
|
||||
|
||||
|
||||
def _children_keys(entry: Dir) -> set[str]:
|
||||
return {coerce_posix_path(key).as_posix() for key in entry.children}
|
||||
|
||||
|
||||
def _source_granted_manifest(root: str | Path = "/workspace", *, source: Path) -> Manifest:
|
||||
return Manifest(root=str(root), extra_path_grants=(SandboxPathGrant(path=str(source)),))
|
||||
|
||||
|
||||
def _user_name(user: object) -> str | None:
|
||||
if user is None:
|
||||
return None
|
||||
if isinstance(user, User):
|
||||
return user.name
|
||||
if isinstance(user, str):
|
||||
return user
|
||||
return str(user)
|
||||
|
||||
|
||||
class _SkillsSession(BaseSandboxSession):
|
||||
def __init__(self, manifest: Manifest) -> None:
|
||||
self.state = TestSessionState(
|
||||
manifest=manifest,
|
||||
snapshot=NoopSnapshot(id=str(uuid.uuid4())),
|
||||
)
|
||||
self.read_users: list[str | None] = []
|
||||
self.write_users: list[str | None] = []
|
||||
self.mkdir_users: list[str | None] = []
|
||||
|
||||
async def start(self) -> None:
|
||||
return None
|
||||
|
||||
async def stop(self) -> None:
|
||||
return None
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
return None
|
||||
|
||||
async def running(self) -> bool:
|
||||
return True
|
||||
|
||||
async def read(self, path: Path, *, user: object = None) -> io.BytesIO:
|
||||
self.read_users.append(_user_name(user))
|
||||
normalized = self.normalize_path(path)
|
||||
return io.BytesIO(normalized.read_bytes())
|
||||
|
||||
async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
|
||||
self.write_users.append(_user_name(user))
|
||||
normalized = self.normalize_path(path)
|
||||
normalized.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = data.read()
|
||||
if isinstance(payload, str):
|
||||
normalized.write_text(payload, encoding="utf-8")
|
||||
else:
|
||||
normalized.write_bytes(bytes(payload))
|
||||
|
||||
async def _exec_internal(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
) -> ExecResult:
|
||||
_ = (command, timeout)
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
async def persist_workspace(self) -> io.IOBase:
|
||||
return io.BytesIO()
|
||||
|
||||
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
||||
_ = data
|
||||
|
||||
async def mkdir(
|
||||
self,
|
||||
path: Path | str,
|
||||
*,
|
||||
parents: bool = False,
|
||||
user: object = None,
|
||||
) -> None:
|
||||
self.mkdir_users.append(_user_name(user))
|
||||
normalized = self.normalize_path(path)
|
||||
normalized.mkdir(parents=parents, exist_ok=True)
|
||||
|
||||
async def ls(
|
||||
self,
|
||||
path: Path | str,
|
||||
*,
|
||||
user: object = None,
|
||||
) -> list[FileEntry]:
|
||||
_ = user
|
||||
normalized = self.normalize_path(path)
|
||||
if not normalized.exists():
|
||||
raise FileNotFoundError(normalized)
|
||||
entries: list[FileEntry] = []
|
||||
for child in sorted(normalized.iterdir(), key=lambda entry: entry.name):
|
||||
stat_result = child.stat()
|
||||
entries.append(
|
||||
FileEntry(
|
||||
path=str(child),
|
||||
permissions=Permissions.from_mode(stat_result.st_mode),
|
||||
owner="owner",
|
||||
group="group",
|
||||
size=stat_result.st_size,
|
||||
kind=EntryKind.DIRECTORY if child.is_dir() else EntryKind.FILE,
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
class TestSkillValidation:
|
||||
def test_rejects_directory_content_artifact(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skill(name="my-skill", description="desc", content=Dir())
|
||||
|
||||
def test_rejects_duplicate_script_paths_after_normalization(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skill(
|
||||
name="my-skill",
|
||||
description="desc",
|
||||
content="literal",
|
||||
scripts={
|
||||
"run.sh": File(content=b"echo one"),
|
||||
Path("run.sh"): File(content=b"echo two"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestSkillsValidation:
|
||||
def test_requires_at_least_one_source(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills()
|
||||
|
||||
def test_rejects_non_directory_from_artifact(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(from_=File(content=b"not-a-dir"))
|
||||
|
||||
def test_rejects_duplicate_skill_names(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
skills=[
|
||||
Skill(name="dup", description="first", content="a"),
|
||||
Skill(name="dup", description="second", content="b"),
|
||||
]
|
||||
)
|
||||
|
||||
def test_rejects_combining_literal_and_from_sources(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
from_=Dir(
|
||||
children={"my-skill": Dir(children={"SKILL.md": File(content=b"imported")})}
|
||||
),
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
)
|
||||
|
||||
def test_rejects_combining_literal_and_lazy_sources(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=Path("skills"))),
|
||||
)
|
||||
|
||||
def test_rejects_absolute_skills_path(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path="/skills",
|
||||
)
|
||||
|
||||
def test_rejects_windows_drive_absolute_skills_path(self) -> None:
|
||||
with pytest.raises(SkillsConfigError) as exc_info:
|
||||
Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path="C:\\skills",
|
||||
)
|
||||
|
||||
assert exc_info.value.context == {
|
||||
"field": "skills_path",
|
||||
"path": "C:/skills",
|
||||
"reason": "absolute",
|
||||
}
|
||||
|
||||
def test_rejects_escape_root_skills_path(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path="../skills",
|
||||
)
|
||||
|
||||
|
||||
class TestSkillsManifest:
|
||||
def test_literals_materialize_full_skill_structure(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[
|
||||
Skill(
|
||||
name="my-skill",
|
||||
description="desc",
|
||||
content="Use this skill.",
|
||||
scripts={"run.sh": File(content=b"echo run")},
|
||||
references={"docs/readme.md": File(content=b"ref")},
|
||||
assets={"images/icon.txt": File(content=b"asset")},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
skill_entry = processed.entries[Path(".agents/my-skill")]
|
||||
assert isinstance(skill_entry, Dir)
|
||||
assert _children_keys(skill_entry) == {"SKILL.md", "assets", "references", "scripts"}
|
||||
|
||||
scripts = skill_entry.children["scripts"]
|
||||
assert isinstance(scripts, Dir)
|
||||
assert _children_keys(scripts) == {"run.sh"}
|
||||
|
||||
references = skill_entry.children["references"]
|
||||
assert isinstance(references, Dir)
|
||||
assert _children_keys(references) == {"docs/readme.md"}
|
||||
|
||||
assets = skill_entry.children["assets"]
|
||||
assert isinstance(assets, Dir)
|
||||
assert _children_keys(assets) == {"images/icon.txt"}
|
||||
|
||||
def test_from_source_is_mapped_to_skills_root(self) -> None:
|
||||
source = Dir(children={"imported": Dir(children={"SKILL.md": File(content=b"imported")})})
|
||||
capability = Skills(from_=source)
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
assert processed.entries[Path(".agents")] is source
|
||||
|
||||
def test_local_dir_from_source_stays_eager_by_default(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(from_=LocalDir(src=src_root))
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
assert processed.entries[Path(".agents")].type == "local_dir"
|
||||
|
||||
def test_lazy_local_dir_source_skips_manifest_materialization(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
assert processed.entries == {}
|
||||
|
||||
def test_lazy_local_dir_rejects_overlapping_manifest_entries(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
manifest = Manifest(
|
||||
root="/workspace",
|
||||
entries={Path(".agents"): Dir()},
|
||||
)
|
||||
|
||||
with pytest.raises(SkillsConfigError) as exc_info:
|
||||
capability.process_manifest(manifest)
|
||||
|
||||
assert exc_info.value.message == "skills lazy_from path overlaps existing manifest entries"
|
||||
assert exc_info.value.context == {
|
||||
"path": ".agents",
|
||||
"source": "lazy_from",
|
||||
"overlaps": [".agents"],
|
||||
}
|
||||
|
||||
def test_literal_skills_allow_existing_manifest_entry_when_content_matches(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[
|
||||
Skill(
|
||||
name="my-skill",
|
||||
description="desc",
|
||||
content="Use this skill.",
|
||||
scripts={"run.sh": File(content=b"echo run")},
|
||||
)
|
||||
]
|
||||
)
|
||||
rendered_skill = capability.skills[0].as_dir_entry()
|
||||
manifest = Manifest(
|
||||
root="/workspace",
|
||||
entries={".agents/my-skill": rendered_skill},
|
||||
)
|
||||
|
||||
processed = capability.process_manifest(manifest)
|
||||
|
||||
assert processed is manifest
|
||||
assert processed.entries[".agents/my-skill"] == rendered_skill
|
||||
|
||||
def test_process_manifest_rejects_exact_path_collision(self) -> None:
|
||||
capability = Skills(skills=[Skill(name="my-skill", description="desc", content="literal")])
|
||||
manifest = Manifest(root="/workspace", entries={Path(".agents/my-skill"): Dir()})
|
||||
|
||||
with pytest.raises(SkillsConfigError):
|
||||
capability.process_manifest(manifest)
|
||||
|
||||
def test_custom_skills_path_is_used_for_manifest_entries(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path=".sandbox/skills",
|
||||
)
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
|
||||
assert processed.entries[Path(".sandbox/skills/my-skill")] == (
|
||||
capability.skills[0].as_dir_entry()
|
||||
)
|
||||
|
||||
|
||||
class TestSkillsInstructions:
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_include_root_and_literal_index(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[
|
||||
Skill(name="z-skill", description="z description", content="z"),
|
||||
Skill(name="a-skill", description="a description", content="a"),
|
||||
]
|
||||
)
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
assert instructions is not None
|
||||
assert instructions.startswith("## Skills\n")
|
||||
assert "### Available skills" in instructions
|
||||
assert "### How to use skills" in instructions
|
||||
assert "- a-skill: a description (file: .agents/a-skill)" in instructions
|
||||
assert "- z-skill: z description (file: .agents/z-skill)" in instructions
|
||||
assert instructions.index(
|
||||
"- a-skill: a description (file: .agents/a-skill)"
|
||||
) < instructions.index("- z-skill: z description (file: .agents/z-skill)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_use_custom_skills_path(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path=".sandbox/skills",
|
||||
)
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
|
||||
assert instructions is not None
|
||||
assert "- my-skill: desc (file: .sandbox/skills/my-skill)" in instructions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_return_none_when_metadata_is_empty(self) -> None:
|
||||
capability = Skills(from_=Dir())
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
assert instructions is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_local_dir_metadata_requires_extra_path_grant(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: hidden-skill\ndescription: outside base\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
|
||||
assert instructions is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_resolve_from_runtime_frontmatter(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
capability = Skills(
|
||||
from_=Dir(
|
||||
children={
|
||||
"dynamic-skill": Dir(
|
||||
children={
|
||||
"SKILL.md": File(
|
||||
content=(
|
||||
b"---\n"
|
||||
b"name: discovered-skill\n"
|
||||
b"description: loaded from runtime frontmatter\n"
|
||||
b"---\n\n"
|
||||
b"# Skill\n"
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
manifest = capability.process_manifest(Manifest(root=str(workspace_root)))
|
||||
session = _SkillsSession(manifest)
|
||||
await session.apply_manifest()
|
||||
capability.bind(session)
|
||||
|
||||
instructions = await capability.instructions(session.state.manifest)
|
||||
|
||||
assert instructions is not None
|
||||
assert (
|
||||
"- discovered-skill: loaded from runtime frontmatter (file: .agents/dynamic-skill)"
|
||||
) in instructions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_resolve_opt_in_lazy_local_dir_metadata(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: discovered-skill\ndescription: local dir metadata\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
|
||||
assert await capability.instructions(Manifest(root="/workspace")) is None
|
||||
|
||||
instructions = await capability.instructions(_source_granted_manifest(source=src_root))
|
||||
|
||||
assert instructions is not None
|
||||
assert (
|
||||
"- discovered-skill: local dir metadata (file: .agents/dynamic-skill)" in instructions
|
||||
)
|
||||
assert "Call `load_skill` with a single skill name from the list" in instructions
|
||||
assert "loaded on demand instead of being present up front" in instructions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_local_dir_metadata_skips_symlinked_skill_directory(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
outside_root = tmp_path / "outside"
|
||||
outside_skill = outside_root / "linked-skill"
|
||||
src_root.mkdir()
|
||||
outside_skill.mkdir(parents=True)
|
||||
(outside_skill / "SKILL.md").write_text(
|
||||
"---\nname: linked-skill\ndescription: linked metadata\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
(src_root / "linked-skill").symlink_to(outside_skill, target_is_directory=True)
|
||||
except OSError as e:
|
||||
pytest.skip(f"symlink unavailable: {e}")
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
|
||||
instructions = await capability.instructions(_source_granted_manifest(source=src_root))
|
||||
|
||||
assert instructions is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_local_dir_load_skill_tool_materializes_single_skill(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
manifest = capability.process_manifest(
|
||||
_source_granted_manifest(workspace_root, source=src_root)
|
||||
)
|
||||
assert manifest.entries == {}
|
||||
|
||||
session = _SkillsSession(manifest)
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await session.read(Path(".agents/dynamic-skill/SKILL.md"))
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
'{"skill_name":"dynamic-skill"}',
|
||||
)
|
||||
|
||||
assert output == {
|
||||
"status": "loaded",
|
||||
"skill_name": "dynamic-skill",
|
||||
"path": ".agents/dynamic-skill",
|
||||
}
|
||||
loaded_skill = workspace_root / ".agents" / "dynamic-skill" / "SKILL.md"
|
||||
assert loaded_skill.read_text(encoding="utf-8") == "# dynamic skill\n"
|
||||
|
||||
|
||||
class TestSkillsLazyLoading:
|
||||
def test_tools_returns_empty_without_lazy_source(self) -> None:
|
||||
capability = Skills(skills=[Skill(name="my-skill", description="desc", content="literal")])
|
||||
|
||||
assert capability.tools() == []
|
||||
|
||||
def test_lazy_tools_require_bound_session(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
|
||||
with pytest.raises(ValueError, match="Skills is not bound to a SandboxSession"):
|
||||
capability.tools()
|
||||
|
||||
def test_lazy_tools_expose_load_skill_after_bind(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root)))
|
||||
|
||||
tools = capability.tools()
|
||||
|
||||
assert len(tools) == 1
|
||||
assert isinstance(tools[0], FunctionTool)
|
||||
assert tools[0].name == "load_skill"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_rejects_non_lazy_capability(self) -> None:
|
||||
capability = Skills(skills=[Skill(name="my-skill", description="desc", content="literal")])
|
||||
|
||||
with pytest.raises(SkillsConfigError):
|
||||
await capability.load_skill("my-skill")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_returns_already_loaded_for_existing_materialized_skill(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8")
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
session = _SkillsSession(_source_granted_manifest(workspace_root, source=src_root))
|
||||
capability.bind(session)
|
||||
await session.write(
|
||||
Path(".agents/dynamic-skill/SKILL.md"),
|
||||
io.BytesIO(b"# already loaded\n"),
|
||||
)
|
||||
|
||||
output = await capability.load_skill("dynamic-skill")
|
||||
|
||||
assert output == {
|
||||
"status": "already_loaded",
|
||||
"skill_name": "dynamic-skill",
|
||||
"path": ".agents/dynamic-skill",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_materializes_with_bound_run_as_user(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
session = _SkillsSession(_source_granted_manifest(workspace_root, source=src_root))
|
||||
capability.bind(session)
|
||||
capability.bind_run_as(User(name="sandbox-user"))
|
||||
|
||||
output = await capability.load_skill("dynamic-skill")
|
||||
|
||||
assert output == {
|
||||
"status": "loaded",
|
||||
"skill_name": "dynamic-skill",
|
||||
"path": ".agents/dynamic-skill",
|
||||
}
|
||||
assert session.read_users == ["sandbox-user"]
|
||||
assert session.write_users == ["sandbox-user"]
|
||||
assert session.mkdir_users
|
||||
assert set(session.mkdir_users) == {"sandbox-user"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_rejects_missing_lazy_source_directory(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=tmp_path / "missing-skills"))
|
||||
)
|
||||
capability.bind(
|
||||
_SkillsSession(
|
||||
_source_granted_manifest(workspace_root, source=tmp_path / "missing-skills")
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(SkillsConfigError):
|
||||
await capability.load_skill("missing-skill")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_rejects_ambiguous_skill_name(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
first_dir = src_root / "skill-one"
|
||||
second_dir = src_root / "skill-two"
|
||||
first_dir.mkdir(parents=True)
|
||||
second_dir.mkdir(parents=True)
|
||||
(first_dir / "SKILL.md").write_text(
|
||||
"---\nname: shared-skill\ndescription: first\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(second_dir / "SKILL.md").write_text(
|
||||
"---\nname: shared-skill\ndescription: second\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root)))
|
||||
|
||||
with pytest.raises(SkillsConfigError):
|
||||
await capability.load_skill("shared-skill")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_metadata_cache_is_reset_on_bind(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text(
|
||||
"---\nname: cached-skill\ndescription: old description\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
|
||||
first_instructions = await capability.instructions(
|
||||
_source_granted_manifest(workspace_root, source=src_root)
|
||||
)
|
||||
skill_md.write_text(
|
||||
"---\nname: cached-skill\ndescription: new description\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
second_instructions = await capability.instructions(
|
||||
_source_granted_manifest(workspace_root, source=src_root)
|
||||
)
|
||||
capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root)))
|
||||
third_instructions = await capability.instructions(
|
||||
_source_granted_manifest(workspace_root, source=src_root)
|
||||
)
|
||||
|
||||
assert first_instructions is not None
|
||||
assert second_instructions is not None
|
||||
assert third_instructions is not None
|
||||
assert "- cached-skill: old description (file: .agents/dynamic-skill)" in first_instructions
|
||||
assert (
|
||||
"- cached-skill: old description (file: .agents/dynamic-skill)" in second_instructions
|
||||
)
|
||||
assert "- cached-skill: new description (file: .agents/dynamic-skill)" in third_instructions
|
||||
Reference in New Issue
Block a user