1032 lines
35 KiB
Python
1032 lines
35 KiB
Python
"""Unit + integration tests for the SOP→DAG compiler.
|
|
|
|
The compiler lives at ``src/opensquilla/skills/meta/sop_compiler.py``.
|
|
This file mirrors its four stages (lexer / parser / resolver / emitter)
|
|
plus integration and acceptance tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from opensquilla.skills.meta.parser import MetaPlanError
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 0: Foundation types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_source_span_carries_line_and_excerpt() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import SourceSpan
|
|
|
|
span = SourceSpan(
|
|
start_line=42,
|
|
start_col=4,
|
|
end_line=42,
|
|
end_col=20,
|
|
excerpt="Run `multi-search-engine`",
|
|
)
|
|
assert span.start_line == 42
|
|
assert "multi-search-engine" in span.excerpt
|
|
|
|
|
|
def test_sop_compile_error_is_meta_plan_error() -> None:
|
|
"""SOPCompileError is a MetaPlanError so the loader's existing failure
|
|
path catches it via its current except clause."""
|
|
from opensquilla.skills.meta.sop_compiler import SOPCompileError
|
|
|
|
exc = SOPCompileError(
|
|
skill_name="meta-x",
|
|
phase_index=2,
|
|
span=None,
|
|
reason="example",
|
|
)
|
|
assert isinstance(exc, MetaPlanError)
|
|
msg = str(exc)
|
|
assert "meta-x" in msg
|
|
assert "Phase 2" in msg or "phase 2" in msg.lower()
|
|
assert "example" in msg
|
|
|
|
|
|
def test_sop_compile_error_renders_excerpt() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import SOPCompileError, SourceSpan
|
|
|
|
span = SourceSpan(
|
|
start_line=10,
|
|
start_col=0,
|
|
end_line=10,
|
|
end_col=30,
|
|
excerpt="- name: introduction",
|
|
)
|
|
exc = SOPCompileError(
|
|
skill_name="meta-y",
|
|
phase_index=5,
|
|
span=span,
|
|
reason="duplicate id",
|
|
)
|
|
msg = str(exc)
|
|
assert "line 10" in msg
|
|
assert "- name: introduction" in msg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 1: Lexer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_lex_recognizes_phase_heading() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import TokenType, _lex
|
|
|
|
body = "## Phase 1: Search\n\nRun `multi-search-engine`. Save as `s`.\n"
|
|
tokens = list(_lex(body))
|
|
types = [t.type for t in tokens]
|
|
assert TokenType.PHASE_HEADING in types
|
|
heading = next(t for t in tokens if t.type == TokenType.PHASE_HEADING)
|
|
assert heading.span.start_line == 1
|
|
assert "Search" in heading.span.excerpt
|
|
|
|
|
|
def test_lex_recognizes_phase_heading_with_annotations() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import TokenType, _lex
|
|
|
|
body = "## Phase 3: Drafting [parallel for_each: section]\n"
|
|
tokens = list(_lex(body))
|
|
heading = next(t for t in tokens if t.type == TokenType.PHASE_HEADING)
|
|
# The lexer captures the full heading text; the parser splits title/annotations.
|
|
assert "parallel for_each" in heading.span.excerpt
|
|
|
|
|
|
def test_lex_recognizes_invocation_and_with_bullets() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import TokenType, _lex
|
|
|
|
body = (
|
|
"## Phase 1: Search\n"
|
|
"Invoke `paper-outline-author` with:\n"
|
|
"- topic: `hello`\n"
|
|
"- outline_format: markdown\n"
|
|
"Save as `outline`.\n"
|
|
)
|
|
tokens = list(_lex(body))
|
|
types = [t.type for t in tokens]
|
|
assert TokenType.INVOCATION_LINE in types
|
|
assert types.count(TokenType.WITH_BULLET) == 2
|
|
assert TokenType.SAVE_AS_LINE in types
|
|
|
|
|
|
def test_lex_skips_fenced_code_blocks_except_for_each() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import TokenType, _lex
|
|
|
|
body = (
|
|
"## Phase 1: Search\n"
|
|
"```python\n"
|
|
"# This is just docs, ignore me\n"
|
|
"## Phase 99: Not a heading\n"
|
|
"```\n"
|
|
"Run `s`. Save as `x`.\n"
|
|
)
|
|
tokens = list(_lex(body))
|
|
# The fake "Phase 99" inside the python fence must NOT be a token
|
|
phase_count = sum(1 for t in tokens if t.type == TokenType.PHASE_HEADING)
|
|
assert phase_count == 1
|
|
|
|
|
|
def test_lex_captures_fenced_for_each_yaml() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import TokenType, _lex
|
|
|
|
body = (
|
|
"## Phase 5: Drafting [parallel for_each: section]\n"
|
|
"```yaml for_each\n"
|
|
"section:\n"
|
|
" - {id: a, name: A}\n"
|
|
" - {id: b, name: B}\n"
|
|
"```\n"
|
|
"Invoke `x` with:\n"
|
|
"- y: z\n"
|
|
"Save as `{{ section.id }}`.\n"
|
|
)
|
|
tokens = list(_lex(body))
|
|
types = [t.type for t in tokens]
|
|
assert TokenType.FENCED_YAML_FOR_EACH in types
|
|
fey = next(t for t in tokens if t.type == TokenType.FENCED_YAML_FOR_EACH)
|
|
# The whole inner YAML is preserved in the excerpt for the parser to load
|
|
assert "section:" in fey.span.excerpt
|
|
assert "id: a" in fey.span.excerpt
|
|
|
|
|
|
def test_lex_returns_source_spans_with_correct_line_numbers() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import TokenType, _lex
|
|
|
|
body = (
|
|
"Hello\n" # line 1
|
|
"\n" # line 2 (blank)
|
|
"## Phase 1: Search\n" # line 3
|
|
"Run `s`. Save as `x`.\n" # line 4
|
|
)
|
|
tokens = list(_lex(body))
|
|
heading = next(t for t in tokens if t.type == TokenType.PHASE_HEADING)
|
|
assert heading.span.start_line == 3
|
|
invocation = next(t for t in tokens if t.type == TokenType.INVOCATION_LINE)
|
|
assert invocation.span.start_line == 4
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 2: Parser
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_single_sequential_phase() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: Search\n"
|
|
"Run `multi-search-engine`. Save as `s`.\n"
|
|
)
|
|
doc = _parse(list(_lex(body)), skill_name="meta-x")
|
|
assert len(doc.phases) == 1
|
|
phase = doc.phases[0]
|
|
assert phase.index == 1
|
|
assert phase.title == "Search"
|
|
assert phase.annotations == {}
|
|
assert len(phase.invocations) == 1
|
|
inv = phase.invocations[0]
|
|
assert inv.skill_name == "multi-search-engine"
|
|
assert inv.kind_hint is None
|
|
assert inv.step_id_template == "s"
|
|
assert inv.with_args == {}
|
|
|
|
|
|
def test_parse_invoke_with_kind_and_args() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: Outline\n"
|
|
"Invoke `paper-outline-author` as agent with:\n"
|
|
"- topic: `{{ inputs.user_message }}`\n"
|
|
"- max_words: 200\n"
|
|
"Save as `outline`.\n"
|
|
)
|
|
doc = _parse(list(_lex(body)), skill_name="meta-x")
|
|
inv = doc.phases[0].invocations[0]
|
|
assert inv.skill_name == "paper-outline-author"
|
|
assert inv.kind_hint == "agent"
|
|
assert inv.with_args["topic"] == "`{{ inputs.user_message }}`"
|
|
assert inv.with_args["max_words"] == "200"
|
|
|
|
|
|
def test_parse_parallel_annotation() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: First\n"
|
|
"Run `a`. Save as `s1`.\n"
|
|
"## Phase 2: Foundation [parallel]\n"
|
|
"Run `b`. Save as `s2`.\n"
|
|
"Run `c`. Save as `s3`.\n"
|
|
)
|
|
doc = _parse(list(_lex(body)), skill_name="meta-x")
|
|
p2 = doc.phases[1]
|
|
assert "parallel" in p2.annotations
|
|
assert len(p2.invocations) == 2
|
|
|
|
|
|
def test_parse_for_each_annotation_with_items() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 5: Drafting [parallel for_each: section]\n"
|
|
"```yaml for_each\n"
|
|
"section:\n"
|
|
" - {id: a, name: A}\n"
|
|
" - {id: b, name: B, extra: X}\n"
|
|
"```\n"
|
|
"Invoke `paper-section-author` with:\n"
|
|
"- section: `{{ section.name }}`\n"
|
|
"Save as `{{ section.id }}`.\n"
|
|
)
|
|
doc = _parse(list(_lex(body)), skill_name="meta-x")
|
|
p = doc.phases[0]
|
|
assert p.annotations.get("parallel for_each") == "section"
|
|
assert p.for_each_var == "section"
|
|
assert len(p.for_each_items) == 2
|
|
assert p.for_each_items[0] == {"id": "a", "name": "A"}
|
|
assert p.for_each_items[1] == {"id": "b", "name": "B", "extra": "X"}
|
|
|
|
|
|
def test_parse_depends_on_annotation_single() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: A\n"
|
|
"Run `s`. Save as `a`.\n"
|
|
"## Phase 2: B [depends_on: a]\n"
|
|
"Run `s`. Save as `b`.\n"
|
|
)
|
|
doc = _parse(list(_lex(body)), skill_name="meta-x")
|
|
p2 = doc.phases[1]
|
|
assert p2.annotations.get("depends_on") == "a"
|
|
|
|
|
|
def test_parse_when_annotation_rejected() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import SOPCompileError, _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: A [when: outputs.x == 'yes']\n"
|
|
"Run `s`. Save as `a`.\n"
|
|
)
|
|
with pytest.raises(SOPCompileError, match="not in MVP scope"):
|
|
_parse(list(_lex(body)), skill_name="meta-x")
|
|
|
|
|
|
def test_parse_missing_save_as_rejected() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import SOPCompileError, _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: A\n"
|
|
"Run `s`.\n"
|
|
"## Phase 2: B\n"
|
|
"Run `t`. Save as `b`.\n"
|
|
)
|
|
with pytest.raises(SOPCompileError, match="Save as"):
|
|
_parse(list(_lex(body)), skill_name="meta-x")
|
|
|
|
|
|
def test_parse_stdin_prose_rejected() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import SOPCompileError, _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: Refbib\n"
|
|
"Run `paper-refbib-stub`. Pipe outputs.search to stdin. Save as `r`.\n"
|
|
)
|
|
with pytest.raises(SOPCompileError, match="stdin"):
|
|
_parse(list(_lex(body)), skill_name="meta-x")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 3: Resolver
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _StubSkillLoader:
|
|
"""Minimal SkillLoader stub for resolver tests.
|
|
|
|
Real SkillLoader is heavy; resolver only needs ``get_by_name``.
|
|
"""
|
|
|
|
def __init__(self, specs: dict[str, dict[str, Any]]) -> None:
|
|
self._specs = specs
|
|
|
|
def get_by_name(self, name: str) -> Any:
|
|
spec = self._specs.get(name)
|
|
if spec is None:
|
|
return None
|
|
from opensquilla.skills.types import SkillLayer, SkillSpec
|
|
|
|
return SkillSpec(
|
|
name=name,
|
|
description=f"{name} description",
|
|
layer=SkillLayer.BUNDLED,
|
|
always=False,
|
|
triggers=[],
|
|
content="",
|
|
kind="skill",
|
|
entrypoint=spec.get("entrypoint"),
|
|
)
|
|
|
|
|
|
def test_resolve_kind_skill_exec_when_skill_has_entrypoint() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import (
|
|
SOPInvocation,
|
|
SourceSpan,
|
|
_resolve_kind,
|
|
)
|
|
|
|
inv = SOPInvocation(
|
|
skill_name="multi-search-engine",
|
|
kind_hint=None,
|
|
with_args={},
|
|
step_id_template="s",
|
|
span=SourceSpan(1, 0, 1, 10, ""),
|
|
)
|
|
loader = _StubSkillLoader({"multi-search-engine": {"entrypoint": {"command": "x"}}})
|
|
result = _resolve_kind(inv, skill_loader=loader, skill_name="meta-x", phase_index=1)
|
|
assert result == "skill_exec"
|
|
|
|
|
|
def test_resolve_kind_agent_when_no_entrypoint() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import (
|
|
SOPInvocation,
|
|
SourceSpan,
|
|
_resolve_kind,
|
|
)
|
|
|
|
inv = SOPInvocation(
|
|
skill_name="paper-outline-author",
|
|
kind_hint=None,
|
|
with_args={},
|
|
step_id_template="o",
|
|
span=SourceSpan(1, 0, 1, 10, ""),
|
|
)
|
|
loader = _StubSkillLoader({"paper-outline-author": {}})
|
|
assert _resolve_kind(inv, skill_loader=loader, skill_name="meta-x", phase_index=1) == "agent"
|
|
|
|
|
|
def test_resolve_kind_explicit_as_agent_overrides_entrypoint() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import (
|
|
SOPInvocation,
|
|
SourceSpan,
|
|
_resolve_kind,
|
|
)
|
|
|
|
inv = SOPInvocation(
|
|
skill_name="multi-search-engine",
|
|
kind_hint="agent", # explicit override
|
|
with_args={},
|
|
step_id_template="s",
|
|
span=SourceSpan(1, 0, 1, 10, ""),
|
|
)
|
|
loader = _StubSkillLoader({"multi-search-engine": {"entrypoint": {"command": "x"}}})
|
|
assert _resolve_kind(inv, skill_loader=loader, skill_name="meta-x", phase_index=1) == "agent"
|
|
|
|
|
|
def test_resolve_kind_unknown_skill_raises() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import (
|
|
SOPCompileError,
|
|
SOPInvocation,
|
|
SourceSpan,
|
|
_resolve_kind,
|
|
)
|
|
|
|
inv = SOPInvocation(
|
|
skill_name="nonexistent-skill",
|
|
kind_hint=None,
|
|
with_args={},
|
|
step_id_template="x",
|
|
span=SourceSpan(5, 0, 5, 30, "Run `nonexistent-skill`. Save as `x`."),
|
|
)
|
|
loader = _StubSkillLoader({})
|
|
with pytest.raises(SOPCompileError, match="not registered"):
|
|
_resolve_kind(inv, skill_loader=loader, skill_name="meta-x", phase_index=1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 4: Emitter (sequential)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_emit_sequential_phases_default_depends_on() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import _emit, _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: First\n"
|
|
"Run `paper-experiment-stub`. Save as `s1`.\n"
|
|
"## Phase 2: Second\n"
|
|
"Run `paper-plot-stub`. Save as `s2`.\n"
|
|
"## Phase 3: Third\n"
|
|
"Invoke `paper-outline-author` as agent with:\n"
|
|
"- topic: `t`\n"
|
|
"Save as `s3`.\n"
|
|
)
|
|
doc = _parse(list(_lex(body)), skill_name="meta-x")
|
|
loader = _StubSkillLoader(
|
|
{
|
|
"paper-experiment-stub": {"entrypoint": {"command": "x"}},
|
|
"paper-plot-stub": {"entrypoint": {"command": "x"}},
|
|
"paper-outline-author": {},
|
|
},
|
|
)
|
|
composition = _emit(doc, skill_loader=loader, skill_name="meta-x")
|
|
steps = composition["steps"]
|
|
assert [s["id"] for s in steps] == ["s1", "s2", "s3"]
|
|
assert steps[0].get("depends_on", []) == []
|
|
assert steps[1]["depends_on"] == ["s1"]
|
|
assert steps[2]["depends_on"] == ["s2"]
|
|
assert steps[0]["kind"] == "skill_exec"
|
|
assert steps[2]["kind"] == "agent"
|
|
assert steps[2]["with"] == {"topic": "`t`"} or steps[2]["with"] == {"topic": "t"}
|
|
|
|
|
|
def test_emit_parallel_phase_yields_sibling_steps() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import _emit, _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: First\n"
|
|
"Run `paper-experiment-stub`. Save as `s1`.\n"
|
|
"## Phase 2: Foundation [parallel]\n"
|
|
"Run `paper-plot-stub`. Save as `s2a`.\n"
|
|
"Run `paper-refbib-stub`. Save as `s2b`.\n"
|
|
"## Phase 3: Third\n"
|
|
"Run `paper-plot-stub`. Save as `s3`.\n"
|
|
)
|
|
doc = _parse(list(_lex(body)), skill_name="meta-x")
|
|
loader = _StubSkillLoader(
|
|
{
|
|
"paper-experiment-stub": {"entrypoint": {"command": "x"}},
|
|
"paper-plot-stub": {"entrypoint": {"command": "x"}},
|
|
"paper-refbib-stub": {"entrypoint": {"command": "x"}},
|
|
},
|
|
)
|
|
composition = _emit(doc, skill_loader=loader, skill_name="meta-x")
|
|
steps = {s["id"]: s for s in composition["steps"]}
|
|
# s2a and s2b are siblings: both depend on s1, neither on each other
|
|
assert steps["s2a"]["depends_on"] == ["s1"]
|
|
assert steps["s2b"]["depends_on"] == ["s1"]
|
|
# s3 depends on both s2 steps
|
|
assert set(steps["s3"]["depends_on"]) == {"s2a", "s2b"}
|
|
|
|
|
|
def test_emit_for_each_expands_into_n_steps_with_loop_substitution() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import _emit, _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: First\n"
|
|
"Run `paper-experiment-stub`. Save as `s1`.\n"
|
|
"## Phase 2: Drafting [parallel for_each: section]\n"
|
|
"```yaml for_each\n"
|
|
"section:\n"
|
|
" - {id: draft_a, name: abstract}\n"
|
|
" - {id: draft_b, name: introduction}\n"
|
|
"```\n"
|
|
"Invoke `paper-outline-author` with:\n"
|
|
"- section: `{{ section.name }}`\n"
|
|
"- outline: `{{ outputs.s1 }}`\n"
|
|
"Save as `{{ section.id }}`.\n"
|
|
)
|
|
doc = _parse(list(_lex(body)), skill_name="meta-x")
|
|
loader = _StubSkillLoader(
|
|
{
|
|
"paper-experiment-stub": {"entrypoint": {"command": "x"}},
|
|
"paper-outline-author": {},
|
|
},
|
|
)
|
|
composition = _emit(doc, skill_loader=loader, skill_name="meta-x")
|
|
steps = {s["id"]: s for s in composition["steps"]}
|
|
assert set(steps.keys()) == {"s1", "draft_a", "draft_b"}
|
|
# Loop substitution happens at compile time: section.name → literal string
|
|
assert steps["draft_a"]["with"]["section"] == "abstract"
|
|
assert steps["draft_b"]["with"]["section"] == "introduction"
|
|
# Runtime templates pass through unchanged
|
|
assert steps["draft_a"]["with"]["outline"] == "{{ outputs.s1 }}"
|
|
assert steps["draft_b"]["with"]["outline"] == "{{ outputs.s1 }}"
|
|
# Both drafts depend on the previous phase
|
|
assert steps["draft_a"]["depends_on"] == ["s1"]
|
|
assert steps["draft_b"]["depends_on"] == ["s1"]
|
|
|
|
|
|
def test_emit_for_each_field_omission_drops_missing_keys() -> None:
|
|
"""The acceptance case: figure_path is only emitted for items that
|
|
define it. Other items have NO figure_path key in their with_args."""
|
|
from opensquilla.skills.meta.sop_compiler import _emit, _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: Drafting [parallel for_each: section]\n"
|
|
"```yaml for_each\n"
|
|
"section:\n"
|
|
" - {id: draft_a, name: abstract}\n"
|
|
" - {id: draft_b, name: results, figure_path: paper/figure_1.pdf}\n"
|
|
"```\n"
|
|
"Invoke `paper-outline-author` with:\n"
|
|
"- section: `{{ section.name }}`\n"
|
|
"- figure_path: `{{ section.figure_path }}`\n"
|
|
"Save as `{{ section.id }}`.\n"
|
|
)
|
|
doc = _parse(list(_lex(body)), skill_name="meta-x")
|
|
loader = _StubSkillLoader({"paper-outline-author": {}})
|
|
composition = _emit(doc, skill_loader=loader, skill_name="meta-x")
|
|
steps = {s["id"]: s for s in composition["steps"]}
|
|
# draft_a does NOT define figure_path → key dropped
|
|
assert "figure_path" not in steps["draft_a"]["with"]
|
|
assert steps["draft_a"]["with"]["section"] == "abstract"
|
|
# draft_b defines figure_path → emitted with the literal value
|
|
assert steps["draft_b"]["with"]["figure_path"] == "paper/figure_1.pdf"
|
|
assert steps["draft_b"]["with"]["section"] == "results"
|
|
|
|
|
|
def test_emit_for_each_mixed_template_rejected() -> None:
|
|
"""A bullet that mixes a missing loop-var ref with other content must
|
|
raise — silent emission with empty interpolation would be confusing."""
|
|
from opensquilla.skills.meta.sop_compiler import SOPCompileError, _emit, _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: Drafting [parallel for_each: section]\n"
|
|
"```yaml for_each\n"
|
|
"section:\n"
|
|
" - {id: draft_a, name: abstract}\n"
|
|
"```\n"
|
|
"Invoke `paper-outline-author` with:\n"
|
|
"- caption: `Figure for {{ section.figure_path }}`\n"
|
|
"Save as `{{ section.id }}`.\n"
|
|
)
|
|
doc = _parse(list(_lex(body)), skill_name="meta-x")
|
|
loader = _StubSkillLoader({"paper-outline-author": {}})
|
|
with pytest.raises(SOPCompileError, match="missing"):
|
|
_emit(doc, skill_loader=loader, skill_name="meta-x")
|
|
|
|
|
|
def test_emit_for_each_duplicate_generated_id_rejected() -> None:
|
|
"""If loop expansion produces a step id that clashes with an existing
|
|
step elsewhere, raise with a clear cross-reference."""
|
|
from opensquilla.skills.meta.sop_compiler import SOPCompileError, _emit, _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: First\n"
|
|
"Run `paper-experiment-stub`. Save as `draft_a`.\n"
|
|
"## Phase 2: Drafting [parallel for_each: section]\n"
|
|
"```yaml for_each\n"
|
|
"section:\n"
|
|
" - {id: draft_a, name: abstract}\n"
|
|
"```\n"
|
|
"Invoke `paper-outline-author` with:\n"
|
|
"- section: `{{ section.name }}`\n"
|
|
"Save as `{{ section.id }}`.\n"
|
|
)
|
|
doc = _parse(list(_lex(body)), skill_name="meta-x")
|
|
loader = _StubSkillLoader(
|
|
{
|
|
"paper-experiment-stub": {"entrypoint": {"command": "x"}},
|
|
"paper-outline-author": {},
|
|
},
|
|
)
|
|
with pytest.raises(SOPCompileError, match="duplicate"):
|
|
_emit(doc, skill_loader=loader, skill_name="meta-x")
|
|
|
|
|
|
def test_emit_depends_on_single_id_override() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import _emit, _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: A\n"
|
|
"Run `paper-experiment-stub`. Save as `a`.\n"
|
|
"## Phase 2: B\n"
|
|
"Run `paper-plot-stub`. Save as `b`.\n"
|
|
"## Phase 3: C [depends_on: a]\n"
|
|
"Run `paper-refbib-stub`. Save as `c`.\n"
|
|
)
|
|
doc = _parse(list(_lex(body)), skill_name="meta-x")
|
|
loader = _StubSkillLoader(
|
|
{
|
|
"paper-experiment-stub": {"entrypoint": {"command": "x"}},
|
|
"paper-plot-stub": {"entrypoint": {"command": "x"}},
|
|
"paper-refbib-stub": {"entrypoint": {"command": "x"}},
|
|
},
|
|
)
|
|
composition = _emit(doc, skill_loader=loader, skill_name="meta-x")
|
|
steps = {s["id"]: s for s in composition["steps"]}
|
|
assert steps["c"]["depends_on"] == ["a"]
|
|
|
|
|
|
def test_emit_depends_on_list_override() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import _emit, _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: A\n"
|
|
"Run `paper-experiment-stub`. Save as `a`.\n"
|
|
"## Phase 2: B\n"
|
|
"Run `paper-plot-stub`. Save as `b`.\n"
|
|
"## Phase 3: C [depends_on: [a, b]]\n"
|
|
"Run `paper-refbib-stub`. Save as `c`.\n"
|
|
)
|
|
doc = _parse(list(_lex(body)), skill_name="meta-x")
|
|
loader = _StubSkillLoader(
|
|
{
|
|
"paper-experiment-stub": {"entrypoint": {"command": "x"}},
|
|
"paper-plot-stub": {"entrypoint": {"command": "x"}},
|
|
"paper-refbib-stub": {"entrypoint": {"command": "x"}},
|
|
},
|
|
)
|
|
composition = _emit(doc, skill_loader=loader, skill_name="meta-x")
|
|
steps = {s["id"]: s for s in composition["steps"]}
|
|
assert set(steps["c"]["depends_on"]) == {"a", "b"}
|
|
|
|
|
|
def test_emit_depends_on_unknown_id_rejected() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import SOPCompileError, _emit, _lex, _parse
|
|
|
|
body = (
|
|
"## Phase 1: A\n"
|
|
"Run `paper-experiment-stub`. Save as `a`.\n"
|
|
"## Phase 2: B [depends_on: nonexistent]\n"
|
|
"Run `paper-plot-stub`. Save as `b`.\n"
|
|
)
|
|
doc = _parse(list(_lex(body)), skill_name="meta-x")
|
|
loader = _StubSkillLoader(
|
|
{
|
|
"paper-experiment-stub": {"entrypoint": {"command": "x"}},
|
|
"paper-plot-stub": {"entrypoint": {"command": "x"}},
|
|
},
|
|
)
|
|
with pytest.raises(SOPCompileError, match="nonexistent"):
|
|
_emit(doc, skill_loader=loader, skill_name="meta-x")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public compile() API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_compile_produces_meta_spec_from_meta_sop() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import compile as sop_compile
|
|
from opensquilla.skills.types import SkillLayer, SkillSpec
|
|
|
|
body = (
|
|
"## Phase 1: First\n"
|
|
"Run `paper-experiment-stub`. Save as `s1`.\n"
|
|
"## Phase 2: Second\n"
|
|
"Run `paper-plot-stub`. Save as `s2`.\n"
|
|
)
|
|
spec_in = SkillSpec(
|
|
name="meta-tiny",
|
|
description="t",
|
|
layer=SkillLayer.BUNDLED,
|
|
always=False,
|
|
triggers=["test"],
|
|
content=body,
|
|
kind="meta_sop",
|
|
)
|
|
loader = _StubSkillLoader(
|
|
{
|
|
"paper-experiment-stub": {"entrypoint": {"command": "x"}},
|
|
"paper-plot-stub": {"entrypoint": {"command": "x"}},
|
|
},
|
|
)
|
|
spec_out = sop_compile(spec_in, skill_loader=loader)
|
|
assert spec_out.kind == "meta"
|
|
assert spec_out.name == "meta-tiny"
|
|
assert spec_out.composition_raw is not None
|
|
steps = spec_out.composition_raw["steps"]
|
|
assert [s["id"] for s in steps] == ["s1", "s2"]
|
|
|
|
|
|
def test_compile_preserves_triggers_and_priority() -> None:
|
|
from opensquilla.skills.meta.sop_compiler import compile as sop_compile
|
|
from opensquilla.skills.types import SkillLayer, SkillSpec
|
|
|
|
body = (
|
|
"## Phase 1: Only\n"
|
|
"Run `paper-experiment-stub`. Save as `s`.\n"
|
|
)
|
|
spec_in = SkillSpec(
|
|
name="meta-tiny",
|
|
description="t",
|
|
layer=SkillLayer.BUNDLED,
|
|
always=False,
|
|
triggers=["t1", "t2"],
|
|
content=body,
|
|
kind="meta_sop",
|
|
meta_priority=42,
|
|
)
|
|
loader = _StubSkillLoader({"paper-experiment-stub": {"entrypoint": {"command": "x"}}})
|
|
spec_out = sop_compile(spec_in, skill_loader=loader)
|
|
assert spec_out.triggers == ["t1", "t2"]
|
|
assert spec_out.meta_priority == 42
|
|
|
|
|
|
def test_compile_rejects_non_meta_sop_input() -> None:
|
|
"""Calling compile on a regular meta skill is a programmer error."""
|
|
from opensquilla.skills.meta.sop_compiler import compile as sop_compile
|
|
from opensquilla.skills.types import SkillLayer, SkillSpec
|
|
|
|
spec_in = SkillSpec(
|
|
name="meta-regular",
|
|
description="t",
|
|
layer=SkillLayer.BUNDLED,
|
|
always=False,
|
|
triggers=["t"],
|
|
content="",
|
|
kind="meta",
|
|
)
|
|
with pytest.raises(ValueError, match="meta_sop"):
|
|
sop_compile(spec_in, skill_loader=_StubSkillLoader({}))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Loader integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_loader_compiles_meta_sop_at_load_time(tmp_path: Path) -> None:
|
|
"""A SKILL.md with kind: meta_sop is detected by the loader and
|
|
compiled before downstream callers see it."""
|
|
|
|
# Bundle two skills the SOP will reference + the SOP skill itself.
|
|
bundled = tmp_path / "bundled"
|
|
bundled.mkdir()
|
|
|
|
(bundled / "tiny-runner").mkdir()
|
|
(bundled / "tiny-runner" / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: tiny-runner\n"
|
|
"description: stub\n"
|
|
"entrypoint:\n"
|
|
" command: python -c 'pass'\n"
|
|
"---\n"
|
|
"# tiny-runner\n",
|
|
)
|
|
|
|
(bundled / "meta-tiny").mkdir()
|
|
(bundled / "meta-tiny" / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: meta-tiny\n"
|
|
"description: tiny SOP\n"
|
|
"kind: meta_sop\n"
|
|
"triggers: [tiny]\n"
|
|
"---\n"
|
|
"## Phase 1: First\n"
|
|
"Run `tiny-runner`. Save as `s1`.\n",
|
|
)
|
|
|
|
from opensquilla.skills.loader import SkillLoader
|
|
|
|
snapshot = tmp_path / "snap.json"
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=snapshot)
|
|
loader.invalidate_cache()
|
|
specs = {s.name: s for s in loader.load_all()}
|
|
|
|
sop = specs.get("meta-tiny")
|
|
assert sop is not None
|
|
# After compilation, the spec should look like a regular meta skill
|
|
assert sop.kind == "meta"
|
|
assert sop.composition_raw is not None
|
|
assert sop.composition_raw["steps"][0]["id"] == "s1"
|
|
|
|
|
|
def test_loader_skips_malformed_meta_sop(tmp_path: Path) -> None:
|
|
"""If the SOP fails to compile, the loader logs and skips the skill
|
|
(matches behaviour for malformed regular skills)."""
|
|
|
|
bundled = tmp_path / "bundled"
|
|
bundled.mkdir()
|
|
|
|
(bundled / "tiny-runner").mkdir()
|
|
(bundled / "tiny-runner" / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: tiny-runner\n"
|
|
"description: stub\n"
|
|
"entrypoint:\n"
|
|
" command: python -c 'pass'\n"
|
|
"---\n"
|
|
"# tiny-runner\n",
|
|
)
|
|
|
|
(bundled / "meta-broken").mkdir()
|
|
(bundled / "meta-broken" / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: meta-broken\n"
|
|
"description: broken SOP\n"
|
|
"kind: meta_sop\n"
|
|
"triggers: [broken]\n"
|
|
"---\n"
|
|
"## Phase 1: Has when annotation [when: outputs.x == 'no']\n"
|
|
"Run `tiny-runner`. Save as `s1`.\n",
|
|
)
|
|
|
|
from opensquilla.skills.loader import SkillLoader
|
|
|
|
snapshot = tmp_path / "snap.json"
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=snapshot)
|
|
loader.invalidate_cache()
|
|
specs = {s.name: s for s in loader.load_all()}
|
|
|
|
# Compilation failed → the skill is not in the loaded set.
|
|
assert "meta-broken" not in specs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI subcommands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_cli_skills_inspect_prints_compiled_dag(tmp_path: Path) -> None:
|
|
"""`opensquilla skills inspect <name>` writes the compiled composition to stdout."""
|
|
|
|
bundled = tmp_path / "bundled"
|
|
bundled.mkdir()
|
|
(bundled / "tiny-runner").mkdir()
|
|
(bundled / "tiny-runner" / "SKILL.md").write_text(
|
|
"---\nname: tiny-runner\ndescription: x\nentrypoint:\n command: python -c 'pass'\n---\n",
|
|
)
|
|
(bundled / "meta-tiny").mkdir()
|
|
(bundled / "meta-tiny" / "SKILL.md").write_text(
|
|
"---\nname: meta-tiny\ndescription: t\nkind: meta_sop\ntriggers: [t]\n---\n"
|
|
"## Phase 1: First\nRun `tiny-runner`. Save as `s1`.\n",
|
|
)
|
|
|
|
from opensquilla.cli.skills_cmd import inspect_compiled_dag
|
|
|
|
output = inspect_compiled_dag(name="meta-tiny", bundled_dir=bundled)
|
|
assert "s1" in output
|
|
assert "tiny-runner" in output
|
|
|
|
|
|
def test_cli_skills_inspect_finds_managed_layer_skill(
|
|
tmp_path: Path, monkeypatch: Any
|
|
) -> None:
|
|
"""`inspect` (no explicit bundled_dir) must see managed-layer meta-skills.
|
|
|
|
Regression: the helper used to build SkillLoader(bundled_dir=...) only,
|
|
leaving managed_dir=None, so a user meta-skill installed under
|
|
~/.opensquilla/skills was always reported "not loaded".
|
|
"""
|
|
|
|
managed = tmp_path / "managed"
|
|
(managed / "meta-managed").mkdir(parents=True)
|
|
(managed / "meta-managed" / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: meta-managed\n"
|
|
"description: d\n"
|
|
"kind: meta\n"
|
|
"triggers: [m]\n"
|
|
"composition:\n"
|
|
" steps:\n"
|
|
" - id: only\n"
|
|
" kind: llm_chat\n"
|
|
" with:\n"
|
|
" system: s\n"
|
|
" task: t\n"
|
|
"---\n"
|
|
"# body\n",
|
|
)
|
|
|
|
from opensquilla.skills import paths as paths_mod
|
|
from opensquilla.skills.paths import SkillLayerDirs
|
|
|
|
monkeypatch.setattr(
|
|
paths_mod,
|
|
"resolve_skill_layer_dirs",
|
|
lambda **_kw: SkillLayerDirs(bundled_dir=None, managed_dir=managed),
|
|
)
|
|
|
|
from opensquilla.cli.skills_cmd import inspect_compiled_dag
|
|
|
|
output = inspect_compiled_dag(name="meta-managed")
|
|
assert "not loaded" not in output, output
|
|
assert "only" in output
|
|
assert "llm_chat" in output
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Acceptance test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _normalize_plan(plan: Any) -> dict[str, Any]:
|
|
"""Reduce a MetaPlan to a dict that's comparable across YAML key orderings.
|
|
|
|
We don't compare top-level `name` / `priority` / `triggers` because
|
|
the SOP and handwritten variants share those frontmatter fields.
|
|
Only the per-step content matters for acceptance.
|
|
"""
|
|
|
|
return {
|
|
step.id: {
|
|
"kind": step.kind,
|
|
"skill": step.skill,
|
|
"depends_on": sorted(step.depends_on),
|
|
"with_args": dict(step.with_args),
|
|
"route": [(c.when, c.to) for c in step.route],
|
|
"tool": step.tool,
|
|
"tool_args": dict(step.tool_args),
|
|
"tool_allowlist": sorted(step.tool_allowlist),
|
|
"output_choices": list(step.output_choices),
|
|
}
|
|
for step in plan.steps
|
|
}
|
|
|
|
|
|
def test_acceptance_meta_paper_write_sop_compiles_to_equivalent_dag() -> None:
|
|
"""The historical SOP-form meta-paper-write compiled to the handwritten
|
|
fixture. The bundled skill may now be authored directly as YAML, but this
|
|
acceptance test still verifies the active bundled plan parses and retains
|
|
the paper quality gates."""
|
|
|
|
from opensquilla.skills.loader import SkillLoader
|
|
from opensquilla.skills.meta.parser import parse_meta_plan
|
|
|
|
project_root = Path(__file__).resolve().parents[2]
|
|
bundled = project_root / "src" / "opensquilla" / "skills" / "bundled"
|
|
|
|
snapshot = Path("/tmp/_sop_acceptance_snap.json")
|
|
if snapshot.exists():
|
|
snapshot.unlink()
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=snapshot)
|
|
loader.invalidate_cache()
|
|
loader.load_all()
|
|
sop_spec = loader.get_by_name("meta-paper-write")
|
|
assert sop_spec is not None
|
|
assert sop_spec.kind == "meta" # after compilation
|
|
sop_plan = parse_meta_plan(sop_spec)
|
|
assert sop_plan is not None
|
|
if not getattr(sop_spec, "sop_source", None):
|
|
step_ids = {step.id for step in sop_plan.steps}
|
|
assert {
|
|
"final_manuscript_package",
|
|
"paper_length_gate",
|
|
"citation_integrity_gate",
|
|
"latex_sanitizer",
|
|
"compile_latex",
|
|
} <= step_ids
|
|
return
|
|
|
|
# Load the handwritten fixture as a bare meta SkillSpec
|
|
fixture_path = (
|
|
project_root / "tests" / "_fixtures" / "meta-paper-write-handwritten.SKILL.md"
|
|
)
|
|
handwritten_text = fixture_path.read_text()
|
|
import yaml as _yaml
|
|
|
|
_, _, after_frontmatter = handwritten_text.partition("---")
|
|
fm_yaml, _, _ = after_frontmatter.partition("---")
|
|
fm = _yaml.safe_load(fm_yaml)
|
|
|
|
from opensquilla.skills.types import SkillLayer, SkillSpec
|
|
|
|
handwritten_spec = SkillSpec(
|
|
name=fm["name"],
|
|
description=fm.get("description", ""),
|
|
layer=SkillLayer.BUNDLED,
|
|
always=False,
|
|
triggers=fm.get("triggers", []),
|
|
content="",
|
|
kind=fm.get("kind", "meta"),
|
|
composition_raw=fm.get("composition"),
|
|
)
|
|
handwritten_plan = parse_meta_plan(handwritten_spec)
|
|
assert handwritten_plan is not None
|
|
|
|
sop_norm = _normalize_plan(sop_plan)
|
|
handwritten_norm = _normalize_plan(handwritten_plan)
|
|
|
|
assert set(sop_norm.keys()) == set(handwritten_norm.keys()), (
|
|
f"step id sets differ:\n"
|
|
f" SOP only: {set(sop_norm.keys()) - set(handwritten_norm.keys())}\n"
|
|
f" Hand only: {set(handwritten_norm.keys()) - set(sop_norm.keys())}"
|
|
)
|
|
|
|
# Acceptance allows the SOP to have STRICTER depends_on (more deps)
|
|
# than the handwritten YAML — the SOP's sequential phase defaults add
|
|
# safe dependencies. We assert handwritten.depends_on ⊆ SOP.depends_on
|
|
# and equal otherwise.
|
|
for step_id, hand in handwritten_norm.items():
|
|
sop = sop_norm[step_id]
|
|
for key in (
|
|
"kind",
|
|
"skill",
|
|
"with_args",
|
|
"route",
|
|
"tool",
|
|
"tool_args",
|
|
"tool_allowlist",
|
|
"output_choices",
|
|
):
|
|
assert sop[key] == hand[key], (
|
|
f"step {step_id} field {key} differs:\n"
|
|
f" SOP: {sop[key]!r}\n"
|
|
f" Hand: {hand[key]!r}"
|
|
)
|
|
assert set(hand["depends_on"]) <= set(sop["depends_on"]), (
|
|
f"step {step_id} depends_on: handwritten {hand['depends_on']!r} "
|
|
f"not subset of SOP {sop['depends_on']!r}"
|
|
)
|