mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
Manifests carry ships/group; release builds hide ships:false coworkers (OPENWORKER_UNSHIPPED=1 restores them). Persona detail serves bundle media; staffing gate reads the registry, not the filtered list.
266 lines
9.9 KiB
Python
266 lines
9.9 KiB
Python
"""SKILLS-SPEC §4.6 — the effective menu: merged scopes − Settings disables − session mutes.
|
||
|
||
One resolver (`effective_skills` / manager.effective_skill_names) feeds the engine catalog,
|
||
the rail list, and the composer popup — the parity test pins that they can never disagree.
|
||
Any-off-wins: a Settings disable can NOT be resurrected by a session override.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from coworker.providers import ModelCapabilities, ProviderClient
|
||
from coworker.skills import (
|
||
SessionSkillStore,
|
||
SkillLoader,
|
||
SkillStore,
|
||
effective_skills,
|
||
skill_catalog_text,
|
||
skill_tools,
|
||
)
|
||
from coworker.server.manager import SessionManager
|
||
|
||
|
||
class ScriptedProvider(ProviderClient):
|
||
def __init__(self, turns=None):
|
||
self._turns = list(turns or [])
|
||
|
||
def complete(self, *, model, messages, tools=None, **settings):
|
||
return self._turns.pop(0)
|
||
|
||
def capabilities(self, model):
|
||
return ModelCapabilities()
|
||
|
||
|
||
def _skill(base: Path, name: str, description: str = "", body: str = "do it") -> None:
|
||
d = base / name
|
||
d.mkdir(parents=True)
|
||
(d / "SKILL.md").write_text(
|
||
f"---\nname: {name}\ndescription: {description}\n---\n\n{body}\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
|
||
@pytest.fixture()
|
||
def manager(tmp_path):
|
||
return SessionManager(workspace=tmp_path / "ws", provider=ScriptedProvider())
|
||
|
||
|
||
# -- resolver ----------------------------------------------------------------------
|
||
|
||
|
||
def test_project_copy_wins_merge(tmp_path):
|
||
_skill(tmp_path / "g", "report", body="generic steps")
|
||
_skill(tmp_path / "p", "report", body="project steps")
|
||
loader = SkillLoader([tmp_path / "g", tmp_path / "p"]) # global first, local last
|
||
assert loader.get("report").instructions == "project steps"
|
||
|
||
|
||
def test_disabled_absent_everywhere():
|
||
assert effective_skills(
|
||
names={"a", "b"}, disabled={"a"}, session_overrides={}
|
||
) == {"b"}
|
||
|
||
|
||
def test_mute_hides_in_that_session_only(tmp_path):
|
||
store = SessionSkillStore(tmp_path / "s.json")
|
||
store.set("s1", "a", False)
|
||
names = {"a", "b"}
|
||
s1 = effective_skills(names=names, disabled=set(), session_overrides=store.get("s1"))
|
||
s2 = effective_skills(names=names, disabled=set(), session_overrides=store.get("s2"))
|
||
assert s1 == {"b"} and s2 == {"a", "b"}
|
||
|
||
|
||
def test_removed_session_inherits_clean(tmp_path):
|
||
store = SessionSkillStore(tmp_path / "s.json")
|
||
store.set("s1", "a", False)
|
||
store.remove_session("s1")
|
||
assert store.get("s1") == {}
|
||
|
||
|
||
def test_mute_of_unknown_skill_is_noop():
|
||
out = effective_skills(
|
||
names={"real"}, disabled=set(), session_overrides={"ghost": False}
|
||
)
|
||
assert out == {"real"}
|
||
|
||
|
||
def test_any_off_wins_both_directions():
|
||
# enabled in Settings + muted in session → out
|
||
assert effective_skills(
|
||
names={"a"}, disabled=set(), session_overrides={"a": False}
|
||
) == set()
|
||
# disabled in Settings + explicit session-on → STILL out (no resurrection)
|
||
assert effective_skills(
|
||
names={"a"}, disabled={"a"}, session_overrides={"a": True}
|
||
) == set()
|
||
|
||
|
||
def test_override_store_survives_reload(tmp_path):
|
||
SessionSkillStore(tmp_path / "s.json").set("s1", "a", False)
|
||
assert SessionSkillStore(tmp_path / "s.json").get("s1") == {"a": False}
|
||
|
||
|
||
def test_concurrent_sessions_same_workspace_independent(tmp_path):
|
||
store = SessionSkillStore(tmp_path / "s.json")
|
||
store.set("s1", "a", False)
|
||
store.set("s2", "b", False)
|
||
assert store.get("s1") == {"a": False}
|
||
assert store.get("s2") == {"b": False}
|
||
|
||
|
||
# -- manager resolution ---------------------------------------------------------------
|
||
|
||
|
||
def test_no_workspace_means_global_only(manager, tmp_path):
|
||
_skill(manager.skill_store.global_dir, "everywhere")
|
||
ws = tmp_path / "elsewhere"
|
||
(ws / ".coworker" / "skills").mkdir(parents=True)
|
||
_skill(ws / ".coworker" / "skills", "local-only")
|
||
assert manager.effective_skill_names("s1") == {"everywhere"}
|
||
assert manager.effective_skill_names("s1", ws) == {"everywhere", "local-only"}
|
||
|
||
|
||
def test_workspace_without_skills_dir_is_fine(manager, tmp_path):
|
||
ws = tmp_path / "bare-ws"
|
||
ws.mkdir()
|
||
assert manager.effective_skill_names("s1", ws) == set()
|
||
|
||
|
||
def test_empty_catalog_is_safe(tmp_path):
|
||
from coworker.tools.registry import ToolRegistry
|
||
|
||
loader = SkillLoader([tmp_path / "nowhere"])
|
||
assert skill_catalog_text(loader) == ""
|
||
reg = ToolRegistry()
|
||
reg.register_all(skill_tools(loader))
|
||
result = reg.execute("load_skill", {"name": "ghost"})
|
||
assert result["error"].startswith("unknown skill")
|
||
assert result["available"] == []
|
||
|
||
|
||
def test_live_load_skill_semantics(manager):
|
||
"""SKILLS-SPEC state table — EVERYTHING the model sees is live per turn:
|
||
· the menu (context_provider) reflects installs/disables from the NEXT MESSAGE —
|
||
no new session needed (mid-session import UX, decided 2026-07-27);
|
||
· load_skill consults live state per call (create-after-build loadable; a Settings
|
||
disable applies to RUNNING sessions; delete ≡ disable to the model);
|
||
· the ONLY thing that persists is what a conversation already loaded (history)."""
|
||
from coworker.agent import build_engine
|
||
from coworker.agents.chat import chat_agent
|
||
|
||
_skill(manager.skill_store.global_dir, "early", body="early body")
|
||
engine = build_engine(
|
||
agent=chat_agent(), # workspace-free agent (persona retired; builder remains)
|
||
provider=ScriptedProvider(),
|
||
skill_filter=lambda: manager.effective_skill_names("s1"),
|
||
)
|
||
# The menu lives in the per-turn context block, not the static system prompt.
|
||
assert "early" in engine.context_provider()
|
||
assert "Available skills" not in engine.messages[0]["content"]
|
||
|
||
# created after build → in the menu from the next turn AND loadable
|
||
manager.create_skill(
|
||
{"name": "late", "description": "d", "instructions": "late body"}
|
||
)
|
||
assert "late" in engine.context_provider()
|
||
loaded = engine.registry.execute("load_skill", {"name": "late"})
|
||
assert loaded["instructions"] == "late body"
|
||
|
||
# disable → gone from the menu next turn, refused on load, listed nowhere
|
||
manager.skill_store.set_enabled("early", False)
|
||
assert "early" not in engine.context_provider()
|
||
refused = engine.registry.execute("load_skill", {"name": "early"})
|
||
assert refused["error"].startswith("unknown skill")
|
||
assert "early" not in refused["available"]
|
||
|
||
# delete ≡ disable, from the model's side
|
||
manager.delete_skill("late")
|
||
assert "late" not in engine.context_provider()
|
||
gone = engine.registry.execute("load_skill", {"name": "late"})
|
||
assert gone["error"].startswith("unknown skill")
|
||
|
||
# re-enable → back next turn, files untouched all along (OFF is parking, not deletion)
|
||
manager.skill_store.set_enabled("early", True)
|
||
assert "early" in engine.context_provider()
|
||
assert (
|
||
engine.registry.execute("load_skill", {"name": "early"})["instructions"]
|
||
== "early body"
|
||
)
|
||
|
||
|
||
def test_disable_countermand_for_loaded_skills(manager):
|
||
"""§3: a skill whose instructions already entered the conversation gets an explicit
|
||
per-turn stop note once disabled/deleted — menus shrinking is passive, instructions in
|
||
history are not. Recomputed fresh: re-enabling clears it; unloaded skills never get one."""
|
||
import json as _json
|
||
|
||
from coworker.agent import build_engine
|
||
from coworker.agents.chat import chat_agent
|
||
|
||
_skill(manager.skill_store.global_dir, "used-one", body="used body")
|
||
_skill(manager.skill_store.global_dir, "unused-one", body="never loaded")
|
||
engine = build_engine(
|
||
agent=chat_agent(), # workspace-free agent (persona retired; builder remains)
|
||
provider=ScriptedProvider(),
|
||
skill_filter=lambda: manager.effective_skill_names("s1"),
|
||
)
|
||
# Simulate a successful load earlier in this conversation (OpenAI message shape).
|
||
engine.messages.append(
|
||
{
|
||
"role": "assistant",
|
||
"tool_calls": [
|
||
{
|
||
"id": "c1",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "load_skill",
|
||
"arguments": _json.dumps({"name": "used-one"}),
|
||
},
|
||
}
|
||
],
|
||
}
|
||
)
|
||
engine.messages.append(
|
||
{
|
||
"role": "tool",
|
||
"tool_call_id": "c1",
|
||
"content": _json.dumps(
|
||
{"name": "used-one", "instructions": "used body", "resources_path": "x"}
|
||
),
|
||
}
|
||
)
|
||
assert "disabled by the user" not in engine.context_provider()
|
||
|
||
manager.skill_store.set_enabled("used-one", False)
|
||
ctx = engine.context_provider()
|
||
assert 'skill "used-one" has been disabled' in ctx
|
||
assert "- used-one:" not in ctx # gone from the menu itself
|
||
assert "- unused-one:" in ctx # untouched skill still offered, no note for it
|
||
|
||
manager.skill_store.set_enabled("used-one", True)
|
||
assert "disabled by the user" not in engine.context_provider() # self-healing
|
||
|
||
manager.delete_skill("used-one") # delete ≡ disable for the countermand too
|
||
assert 'skill "used-one" has been disabled' in engine.context_provider()
|
||
|
||
|
||
def test_parity_catalog_vs_rail_view(manager):
|
||
"""The §3 invariant: the engine's menu and the rail payload come from one resolver."""
|
||
_skill(manager.skill_store.global_dir, "alpha")
|
||
_skill(manager.skill_store.global_dir, "beta")
|
||
_skill(manager.skill_store.global_dir, "gamma")
|
||
manager.skill_store.set_enabled("beta", False) # Settings disable → gone from BOTH
|
||
manager.session_skills.set("s1", "gamma", False) # session mute → rail row off
|
||
|
||
menu = manager.effective_skill_names("s1")
|
||
view = manager.session_skills_view("s1")["skills"]
|
||
view_names = {r["name"] for r in view}
|
||
view_on = {r["name"] for r in view if r["enabled"]}
|
||
|
||
assert menu == {"alpha"}
|
||
assert view_names == {"alpha", "gamma"} # disabled hidden; muted still listed (toggle)
|
||
assert view_on == menu # what's ON in the rail == what the model sees
|