Files
openworker/tests/test_skills_sessions.py
T
Rohit C Prasad 19e10f0e76 Shipping lineup: remove Chat, ship Code disabled, gate unshipped personas
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.
2026-08-20 17:59:48 -07:00

266 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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