Add support for Skills (#391)

Global & per-workspace skill.
Per-persona skills will be improved later as we re-design that abstraction, as per roadmap.
This commit is contained in:
Devika Verma
2026-08-01 08:50:53 -07:00
committed by GitHub
parent e0cb129d20
commit 70e4610a3e
40 changed files with 3853 additions and 95 deletions
+77 -6
View File
@@ -7,7 +7,7 @@ the skill catalog (progressive disclosure) + load_skill into a TurnEngine.
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
from typing import Any, Callable, Optional
from .agents import Agent, AgentContext, code_agent
from .automation import scheduling_tools
@@ -30,7 +30,7 @@ from .roots import RootDir, normalize_roots, render_context
from .providers import ProviderClient, ProviderRouter
from .overrides import RiskOverrideStore
from .secrets import SecretStore, state_dir
from .skills import SkillLoader, skill_catalog_text, skill_tools
from .skills import SkillLoader, save_skill_tool, skill_catalog_text, skill_tools
from .tools import ToolRegistry
from .tools.ask import ask_user_tool
from .tools.directories import request_directory_tool
@@ -99,6 +99,38 @@ def _enabled_connector_tools(secrets: SecretStore) -> tuple[set[str], set[str]]:
return enabled_connectors, enabled_tools
def _loaded_skill_names(messages: list[dict[str, Any]]) -> set[str]:
"""Skills whose instructions successfully entered THIS conversation (a load_skill call
with a non-error result). Drives the disable countermand: a menu quietly shrinking is
passive, but instructions already in history keep steering the model unless it is
explicitly asked to stop."""
import json as _json
results: dict[str, str] = {}
for m in messages:
if m.get("role") == "tool" and m.get("tool_call_id"):
content = m.get("content")
results[m["tool_call_id"]] = (
content if isinstance(content, str) else _json.dumps(content)
)
loaded: set[str] = set()
for m in messages:
if m.get("role") != "assistant" or not m.get("tool_calls"):
continue
for tc in m["tool_calls"]:
fn = tc.get("function") or {}
if fn.get("name") != "load_skill":
continue
try:
name = str(_json.loads(fn.get("arguments") or "{}").get("name", ""))
except Exception:
continue
result = results.get(tc.get("id", ""), "")
if name and '"instructions"' in result:
loaded.add(name)
return loaded
def _skill_dirs(workspace: Optional[Path]) -> list[Path]:
dirs = [state_dir() / "skills"]
if workspace is not None:
@@ -133,6 +165,8 @@ def build_engine(
channel_buffer: Optional[Any] = None,
routing_targets: Optional[list[str]] = None,
connector_filter: Optional[set[str]] = None,
# A set (static snapshot) or a zero-arg callable (live, re-evaluated per load_skill).
skill_filter: Optional[set[str] | Callable[[], set[str]]] = None,
) -> TurnEngine:
ws = Path(workspace).expanduser().resolve() if workspace else None
if agent.needs_workspace and ws is None:
@@ -260,10 +294,22 @@ def build_engine(
instructions = f"{instructions}\n\n{block}"
skill_loader = SkillLoader(_skill_dirs(ws))
registry.register_all(skill_tools(skill_loader))
catalog = skill_catalog_text(skill_loader)
if catalog:
instructions = f"{instructions}\n\n{catalog}"
# Per-session effective menu (SKILLS-SPEC §3). The manager passes a CALLABLE so
# load_skill consults the LIVE state per call (a Settings disable applies to running
# sessions; a skill created after this build is still loadable). The catalog itself
# is injected per turn via context_provider (below), NOT here — so the menu the model
# sees is also live: skill changes apply from the next message, no new session needed.
# Default None preserves CLI / direct callers.
registry.register_all(skill_tools(skill_loader, allowed=skill_filter))
# The worker-authors door (SKILLS-SPEC §5.2): save_skill proposes installing a finished
# skill; requires_approval routes it through the standard approval card, so the review-
# before-save rule holds without any bespoke plumbing. Bundled files may only come from
# this session's roots.
registry.register(
save_skill_tool(
allowed_dirs=[r.path for r in (root_list or [])] or ([ws] if ws else [])
)
)
# User-local risk overrides (mainly to relax MCP's conservative default). Empty store →
# no-op; never written by persona loading (the no-self-grant rule).
@@ -294,6 +340,10 @@ def build_engine(
else None
)
# Late-bound engine ref: the closure needs the conversation history (for the disable
# countermand) but the engine is constructed after the closure. Filled below.
_engine_box: list = []
def context_provider() -> str:
parts = []
if permissions.mode is Mode.PLAN:
@@ -304,6 +354,26 @@ def build_engine(
ctx = roots_context()
if ctx:
parts.append(ctx)
# Live skill menu (SKILLS-SPEC §4.1): recomputed every turn like the roots list, so
# a skill installed/enabled/disabled mid-session applies from the NEXT MESSAGE —
# no new session, no lost context.
skill_loader.rescan()
allowed = skill_filter() if callable(skill_filter) else skill_filter
skills_ctx = skill_catalog_text(skill_loader, allowed=allowed)
if skills_ctx:
parts.append(skills_ctx)
# Disable countermand (§3): instructions already loaded into this conversation keep
# steering the model even after the skill is turned off/deleted — history can't be
# un-read. So a loaded-but-no-longer-available skill gets an explicit stop note,
# recomputed fresh each turn (re-enable → the note disappears; never persisted).
eng = _engine_box[0] if _engine_box else None
if eng is not None:
available = set(skill_loader.names()) if allowed is None else set(allowed)
for name in sorted(_loaded_skill_names(eng.messages) - available):
parts.append(
f'Note: the skill "{name}" has been disabled by the user — stop '
"following its instructions from here on."
)
return "\n\n".join(parts)
engine = TurnEngine(
@@ -336,6 +406,7 @@ def build_engine(
"workspace": str(ws) if ws else "",
}
engine.skill_loader = skill_loader # type: ignore[attr-defined]
_engine_box.append(engine) # late-bind for the countermand (see context_provider)
return engine
+1 -1
View File
@@ -49,7 +49,7 @@ ABOUT: dict[str, str] = {
"Multiple accounts connect side by side.",
"monday": "Work with your monday.com boards — read items, summarize and "
"aggregate board data, create items, and post updates. One-click sign-in "
"runs entirely on this Mac against monday.com's own agent service; agents "
"runs entirely on this computer against monday.com's own agent service; agents "
"get a small curated set of its tools, never the full catalog.",
"asana": "Keep up with your Asana work — search and read tasks and "
"projects, create tasks, and comment. Connects with a personal access "
+2 -2
View File
@@ -66,7 +66,7 @@ class ConnectorDescriptor:
# doesn't carry (e.g. "calendar" must surface Outlook, not just Google Calendar).
aliases: tuple = ()
# Vendor-hosted MCP server URL → this connector is MCP-BACKED: one-click connect
# runs the local MCP OAuth flow (DCR, tokens on this Mac — no broker), and the
# runs the local MCP OAuth flow (DCR, tokens on this computer — no broker), and the
# tool surface is the PINNED subset in tool_defs (names `mcp__<name>__<tool>`),
# never the vendor's full catalog (drift can only shrink capability, not grow it).
# A connector may carry BOTH mcp_url and manual fields (jira): the profile's
@@ -704,7 +704,7 @@ DESCRIPTORS: list[ConnectorDescriptor] = [
fields=[],
instructions=[
"One click connects via monday.com sign-in in your browser.",
"Sign-in is fully local — tokens stay on this Mac.",
"Sign-in is fully local — tokens stay on this computer.",
],
available=True,
),
+13 -2
View File
@@ -163,13 +163,20 @@ class TurnEngine:
# -- main loop --------------------------------------------------------------
async def run(
self, user_input: "str | list", *, source: Optional[dict[str, Any]] = None
self,
user_input: "str | list",
*,
source: Optional[dict[str, Any]] = None,
display: Optional[str] = None,
) -> AsyncIterator[Event]:
# `user_input` is a string, or OpenAI content-parts (text + image_url) for attachments.
# `source` (a MessageSource dict) is a display-only sidecar for connector messages: it
# rides on the persisted user message + the TURN_START event, but is stripped before the
# message reaches a provider (see `_outbound_messages`). `content` stays the framed text.
# `ts` (unix seconds, stamped on every appended message) is the same kind of sidecar.
# `display` is the same split for force-run skills (SKILLS-SPEC §4.1 #3): the user's
# literal "/skill …" line for the transcript, while `content` carries the model-facing
# framing. `ts` (unix seconds, stamped on every appended message) is the same kind of
# sidecar.
message: dict[str, Any] = {
"role": "user",
"content": user_input,
@@ -177,11 +184,15 @@ class TurnEngine:
}
if source is not None:
message["source"] = source
if display is not None:
message["_display"] = display
self.messages.append(message)
self._cancel.clear()
data: dict[str, Any] = {"input": user_input}
if source is not None:
data["source"] = source
if display is not None:
data["display"] = display
yield Event(EventType.TURN_START, data)
async for event in self._loop():
yield event
+97 -7
View File
@@ -8,6 +8,8 @@ proxy so any OpenAI-format client can use the runtime as a backend.
from __future__ import annotations
import asyncio
import base64
import binascii
import json
import os
import re
@@ -388,6 +390,29 @@ def create_app(manager: SessionManager) -> FastAPI:
manager.unattended.set(session_id, on)
return {"ok": True, "session_id": session_id, "unattended": on}
@app.get("/v1/sessions/{session_id}/skills")
def session_skills(session_id: str, workspace: str = "") -> dict[str, Any]:
# The rail's Skills group + the composer popup both read this (SKILLS-SPEC §4.1).
return manager.session_skills_view(session_id, workspace or None)
@app.post("/v1/sessions/{session_id}/skills")
def set_session_skill(session_id: str, body: dict) -> dict[str, Any]:
# A session mute. `clear` drops the override (inherit again); otherwise explicit
# on/off. Nothing on disk changes — Settings owns permanent state.
body = body or {}
skill = str(body.get("skill", "")).strip()
if not skill:
return {"ok": False, "error": "skill required"}
if body.get("clear"):
manager.session_skills.clear(session_id, skill)
else:
manager.session_skills.set(
session_id, skill, bool(body.get("enabled", False))
)
return manager.session_skills_view(
session_id, str(body.get("workspace", "")) or None
)
@app.get("/v1/sessions/{session_id}/connections")
def session_connections(session_id: str, persona: str = "") -> dict[str, Any]:
# `persona` is the GUI's hint for brand-new sessions (no record yet) — without it the
@@ -555,8 +580,45 @@ def create_app(manager: SessionManager) -> FastAPI:
)
@app.get("/v1/skills")
def skills() -> dict[str, Any]:
return {"skills": manager.list_skills()}
def skills(workspace: str = "") -> dict[str, Any]:
return {"skills": manager.list_skills(workspace or None)}
@app.post("/v1/skills")
def create_skill(body: dict) -> dict[str, Any]:
return manager.create_skill(body or {})
@app.patch("/v1/skills/{name}")
def update_skill(name: str, body: dict) -> dict[str, Any]:
return manager.update_skill(name, body or {})
@app.delete("/v1/skills/{name}")
def delete_skill(name: str, workspace: str = "") -> dict[str, Any]:
return manager.delete_skill(name, workspace or None)
@app.post("/v1/skills/{name}/move")
def move_skill(name: str, body: dict) -> dict[str, Any]:
return manager.move_skill(name, body or {})
@app.post("/v1/skills/{name}/reveal")
def reveal_skill(name: str, body: dict) -> dict[str, Any]:
# §6 "Show folder": open the skill's folder in the OS file manager (local machine).
return manager.reveal_skill(name, str((body or {}).get("workspace", "")) or None)
@app.post("/v1/skills/upload")
def stage_skill_upload(body: dict) -> dict[str, Any]:
# Stage → preview; nothing is installed until /upload/confirm (SKILLS-SPEC §4.2).
data_b64 = str((body or {}).get("data_b64", ""))
if not data_b64:
return {"ok": False, "error": "No archive supplied."}
try:
data = base64.b64decode(data_b64, validate=True)
except (ValueError, binascii.Error):
return {"ok": False, "error": "Invalid archive encoding."}
return manager.stage_skill_upload(data, str((body or {}).get("filename", "")))
@app.post("/v1/skills/upload/confirm")
def confirm_skill_upload(body: dict) -> dict[str, Any]:
return manager.confirm_skill_upload(body or {})
@app.get("/v1/workspaces/recent")
def recent_workspaces() -> dict[str, Any]:
@@ -1729,11 +1791,15 @@ def create_app(manager: SessionManager) -> FastAPI:
"iteration_end",
}
async def run_turn(content, *, retry: bool = False) -> None:
async def run_turn(content, *, retry: bool = False, display=None) -> None:
# The receive loop atomically claims this session before scheduling the task.
# Keeping the claim outside prevents two back-to-back frames from both starting.
try:
events = engine.retry() if retry else engine.run(content)
events = (
engine.retry()
if retry
else engine.run(content, display=display)
)
async for event in events:
# Broadcast to every socket viewing this session (this socket included — it's a
# registered client), so a second view of the same session stays in sync too.
@@ -1759,13 +1825,13 @@ def create_app(manager: SessionManager) -> FastAPI:
# or flush an in-progress assistant stream in the GUI.
await ws.send_json({"type": "input_rejected", "data": {"error": reason}})
async def claim_turn(*, retry: bool = False, content=None) -> None:
async def claim_turn(*, retry: bool = False, content=None, display=None) -> None:
if not manager.try_mark_running(session_id):
await reject_input(
"This session is already running a turn. Wait for it to finish or stop it."
)
return
asyncio.create_task(run_turn(content, retry=retry))
asyncio.create_task(run_turn(content, retry=retry, display=display))
try:
while True:
@@ -1918,10 +1984,34 @@ def create_app(manager: SessionManager) -> FastAPI:
if model is not None and not isinstance(model, str):
await reject_input("Invalid model: expected a string.")
continue
# Force-run (SKILLS-SPEC §4.1 #3): the composer's `/skill` pick rides as a
# separate field. Validated against the session's effective menu — a muted
# or unknown skill is a visible error, never a silent no-op (§4.6 #15).
# The model-facing framing goes into `content`; the transcript shows the
# user's literal "/name …" line via the `_display` sidecar (one bubble).
skill = message.get("skill")
display = None
if skill is not None:
if not isinstance(skill, str) or not skill.strip():
await reject_input("Invalid skill: expected a name.")
continue
skill = skill.strip()
menu = manager.effective_skill_names(session_id, workspace)
if skill not in menu:
await reject_input(
f"Skill '{skill}' is not available in this session."
)
continue
display = f"/{skill}" + (f" {text}" if text else "")
text = (
f'Use the skill "{skill}" for this request: first call '
f'load_skill("{skill}") and follow its instructions.'
+ (f"\n\n{text}" if text else "")
)
await _apply_model(model)
if text or attachments:
content = build_user_content(text, attachments)
await claim_turn(content=content)
await claim_turn(content=content, display=display)
else:
await reject_input(f"Unknown WebSocket message type: {kind}.")
except WebSocketDisconnect:
+217 -11
View File
@@ -82,7 +82,12 @@ from ..providers import (
)
from ..secrets import SecretStore, state_dir
from ..sessions import SessionRecord
from ..skills import SkillLoader
from ..skills import (
SessionSkillStore,
SkillLoader,
SkillStore,
effective_skills,
)
_SCOPES = {s.value for s in Scope}
@@ -225,6 +230,11 @@ class SessionManager:
self.session_connections = SessionConnectionStore(
base / "session_connections.json"
)
# Skills (SKILLS-SPEC §4): folder-backed CRUD + per-session mutes. The effective menu
# gates the engine's skill catalog the same way effective_connectors gates connector
# tools — one resolver feeds the catalog injection, the rail, and the composer popup.
self.skill_store = SkillStore()
self.session_skills = SessionSkillStore(base / "session_skills.json")
# Dead-letter: inbound messages with no destination + background-turn failures, so neither
# vanishes silently (a debugging/visibility surface, not a redelivery queue).
self.unrouted = UnroutedStore(base / "unrouted.json")
@@ -454,6 +464,9 @@ class SessionManager:
routing_targets=self._routing_targets(session_id, agent),
# Per-session connection hierarchy: expose only effective-enabled connectors' tools.
connector_filter=self.effective_connectors(session_id, agent_name),
# Per-session skill menu, LIVE (SKILLS-SPEC §3): a callable so load_skill sees
# disables/new skills immediately; the catalog snapshot is taken at build.
skill_filter=lambda sid=session_id, w=ws: self.effective_skill_names(sid, w),
)
# An automation run rebuilt here (manual "Run now" over WS, durable resume) still
# carries its task's standing allowances — the rules live on the task record.
@@ -1290,7 +1303,7 @@ class SessionManager:
MAX_BINARY_PREVIEW = 25 * 1024 * 1024 # base64-over-JSON gets heavy past this
def _artifact_target(
self, session_id: str, path: str
self, session_id: str, path: str, *, allow_dir: bool = False
) -> tuple[Optional[Path], Optional[str]]:
"""Resolve an artifact path under the session's workspace, or (None, error)."""
record = self.session_store.load(session_id)
@@ -1303,14 +1316,36 @@ class SessionManager:
target.relative_to(root)
except ValueError:
return None, "path escapes workspace"
if allow_dir and target.is_dir():
return target, None
if not target.is_file():
return None, "not found"
return None, (
"This isn't in the conversation's folder anymore — it may have been "
"moved or deleted."
)
return target, None
def read_artifact(self, session_id: str, path: str) -> dict[str, Any]:
target, err = self._artifact_target(session_id, path)
# Folders are readable too (a model sometimes links a whole package, e.g. a skill
# build dir): return a listing the viewer can render instead of a dead end.
target, err = self._artifact_target(session_id, path, allow_dir=True)
if target is None:
return {"ok": False, "error": err}
if target.is_dir():
entries: list[dict[str, Any]] = []
try:
children = sorted(
target.iterdir(), key=lambda c: (c.is_file(), c.name.lower())
)
except OSError as exc:
return {"ok": False, "error": str(exc)}
for child in children[:500]:
try:
size = 0 if child.is_dir() else child.stat().st_size
except OSError:
continue
entries.append({"name": child.name, "dir": child.is_dir(), "size": size})
return {"ok": True, "path": path, "kind": "folder", "entries": entries}
kind = _artifact_kind(target)
if kind == "office":
# PowerPoint/Word binaries can't be previewed inline; the UI offers
@@ -1364,27 +1399,29 @@ class SessionManager:
import subprocess
import sys
target, err = self._artifact_target(session_id, path)
target, err = self._artifact_target(session_id, path, allow_dir=True)
if target is None:
return {"ok": False, "error": err}
# A folder "opens" as itself in the file manager, whatever the mode.
is_dir = target.is_dir()
try:
if sys.platform == "darwin":
args = (
["open", "-R", str(target)]
if mode == "reveal"
if mode == "reveal" and not is_dir
else ["open", str(target)]
)
subprocess.Popen(
args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
elif sys.platform == "win32":
if mode == "reveal":
if mode == "reveal" and not is_dir:
# Explorer wants the path glued to the switch: /select,<path>
subprocess.Popen(["explorer", f"/select,{target}"])
else:
os.startfile(str(target)) # type: ignore[attr-defined] # open in default app
else: # Linux/BSD
tgt = str(target.parent) if mode == "reveal" else str(target)
tgt = str(target.parent) if mode == "reveal" and not is_dir else str(target)
subprocess.Popen(
["xdg-open", tgt],
stdout=subprocess.DEVNULL,
@@ -2700,6 +2737,9 @@ class SessionManager:
# Scheduled runs respect the same per-session connection hierarchy as live sessions:
# expose only the persona's effective-enabled connectors' tools (§4.3).
connector_filter=self.effective_connectors(session_id, task.agent),
skill_filter=lambda sid=session_id, w=task.workspace: (
self.effective_skill_names(sid, w)
),
)
self._seed_task_permissions(engine, task)
return engine
@@ -3683,6 +3723,8 @@ class SessionManager:
self.mention_sessions.remove_session(session_id)
# ...and drops its per-session connector overrides (§4.2, like subscriptions).
self.session_connections.remove_session(session_id)
# ...and its per-session skill mutes (SKILLS-SPEC §3 — mutes die with the session).
self.session_skills.remove_session(session_id)
# ...and closes its pending Inbox items — an orphaned approval/question can never be
# meaningfully answered (owner call, 2026-07-03).
self.inbox.resolve_session(session_id)
@@ -3758,9 +3800,173 @@ class SessionManager:
def list_agents(self) -> list[dict[str, Any]]:
return _list_agents()
def list_skills(self) -> list[dict[str, Any]]:
loader = SkillLoader([state_dir() / "skills"])
return loader.catalog()
# -- skills (SKILLS-SPEC §4.4) ------------------------------------------------
def list_skills(self, workspace: Optional[str] = None) -> list[dict[str, Any]]:
"""Enriched rows for the Settings screen (scope/source/enabled). Optional workspace
adds that project's skills, with project copies shadowing same-named global ones."""
return self.skill_store.rows(workspace or None)
def reveal_skill(
self, name: str, workspace: Optional[str] = None
) -> dict[str, Any]:
"""Open the skill's folder in the OS file manager (§6 "Show folder" — the power-user
window into folder-is-truth). Same local-machine rationale as reveal_artifact."""
import subprocess
import sys
try:
folder, _scope = self.skill_store.find(name, workspace or None)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
try:
if sys.platform == "darwin":
subprocess.Popen(
["open", str(folder)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
elif sys.platform == "win32":
import os
os.startfile(str(folder)) # type: ignore[attr-defined]
else:
subprocess.Popen(
["xdg-open", str(folder)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except OSError as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True}
def effective_skill_names(
self, session_id: str, workspace: Optional[str | Path] = None
) -> set[str]:
"""The session's skill menu (§3): merged scopes Settings disables session mutes.
The single resolver behind the engine catalog, the rail list, and the composer popup."""
dirs = [self.skill_store.global_dir]
if workspace:
dirs.append(self.skill_store.project_dir(workspace))
loader = SkillLoader(dirs)
return effective_skills(
names=set(loader.names()),
disabled=self.skill_store.disabled_names(),
session_overrides=self.session_skills.get(session_id),
)
def session_skills_view(
self, session_id: str, workspace: Optional[str] = None
) -> dict[str, Any]:
"""The rail payload: every in-scope, Settings-enabled skill with its mute state."""
disabled = self.skill_store.disabled_names()
overrides = self.session_skills.get(session_id)
rows = [
{
"name": r["name"],
"description": r["description"],
"scope": r["scope"],
"enabled": overrides.get(r["name"], True),
}
for r in self.skill_store.rows(workspace or None)
if r["name"] not in disabled
]
return {"skills": rows}
def _scratch_workspace_error(self, workspace: Any) -> Optional[dict[str, Any]]:
"""Refuse skill WRITES into a per-conversation scratch dir — a skill saved there is
stranded in a throwaway folder. Backend chokepoint: guards every entry path (UI,
REST, future import), not just the flows the GUI happens to gate."""
if not workspace:
return None
try:
ws = Path(str(workspace)).expanduser().resolve()
if ws.is_relative_to(self.scratch_base().resolve()):
return {
"ok": False,
"error": (
"That folder is a temporary session space — skills saved there "
"would be lost. Save it globally or pick a real project."
),
}
except OSError:
pass
return None
def create_skill(self, body: dict[str, Any]) -> dict[str, Any]:
blocked = self._scratch_workspace_error(body.get("workspace"))
if blocked:
return blocked
try:
created = self.skill_store.create(
name=str(body.get("name", "")),
description=str(body.get("description", "")),
instructions=str(body.get("instructions", "")),
scope=str(body.get("scope", "global") or "global"),
workspace=body.get("workspace") or None,
)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True, "skill": created}
def update_skill(self, name: str, body: dict[str, Any]) -> dict[str, Any]:
try:
if "enabled" in body:
self.skill_store.set_enabled(name, bool(body["enabled"]))
if body.get("description") is not None or body.get("instructions") is not None:
self.skill_store.update(
name,
description=body.get("description"),
instructions=body.get("instructions"),
workspace=body.get("workspace") or None,
)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True}
def delete_skill(self, name: str, workspace: Optional[str] = None) -> dict[str, Any]:
try:
self.skill_store.delete(name, workspace or None)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True}
def move_skill(self, name: str, body: dict[str, Any]) -> dict[str, Any]:
# Moving INTO project scope must not target a scratch dir (moving OUT is fine —
# that's the rescue path for already-stranded skills).
if str(body.get("scope", "")) == "project":
blocked = self._scratch_workspace_error(body.get("workspace"))
if blocked:
return blocked
try:
moved = self.skill_store.move(
name,
to_scope=str(body.get("scope", "")),
workspace=body.get("workspace") or None,
)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True, "skill": moved}
def stage_skill_upload(self, data: bytes, filename: str = "") -> dict[str, Any]:
try:
preview = self.skill_store.stage_upload(data, filename)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True, **preview}
def confirm_skill_upload(self, body: dict[str, Any]) -> dict[str, Any]:
blocked = self._scratch_workspace_error(body.get("workspace"))
if blocked:
return blocked
try:
saved = self.skill_store.confirm_upload(
str(body.get("token", "")),
scope=str(body.get("scope", "global") or "global"),
workspace=body.get("workspace") or None,
)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True, "skill": saved}
def list_memory(self) -> list[dict[str, Any]]:
return [
+18 -1
View File
@@ -1,3 +1,20 @@
from .base import Skill, SkillLoader, skill_catalog_text, skill_tools
from .store import (
SessionSkillStore,
SkillStore,
effective_skills,
save_skill_tool,
validate_name,
)
__all__ = ["Skill", "SkillLoader", "skill_catalog_text", "skill_tools"]
__all__ = [
"Skill",
"SkillLoader",
"skill_catalog_text",
"skill_tools",
"SkillStore",
"SessionSkillStore",
"effective_skills",
"save_skill_tool",
"validate_name",
]
+37 -7
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from typing import Callable, Optional, Union
import aisuite as ai
@@ -27,9 +27,17 @@ class Skill:
class SkillLoader:
def __init__(self, dirs: list[str | Path]) -> None:
self._dirs = [Path(d) for d in dirs]
self._skills: dict[str, Skill] = {}
for directory in dirs:
self._discover(Path(directory))
self.rescan()
def rescan(self) -> None:
"""Re-read the skill dirs. load_skill rescans on a miss so a skill created AFTER
the session's engine was built is still loadable (the catalog line stays static
until the next session, but an explicitly requested skill must not 404)."""
self._skills = {}
for directory in self._dirs:
self._discover(directory)
def _discover(self, directory: Path) -> None:
if not directory.is_dir():
@@ -81,8 +89,12 @@ def _parse_skill(md: Path) -> Skill:
)
def skill_catalog_text(loader: SkillLoader) -> str:
catalog = loader.catalog()
def skill_catalog_text(
loader: SkillLoader, allowed: Optional[set[str]] = None
) -> str:
catalog = [
c for c in loader.catalog() if allowed is None or c["name"] in allowed
]
if not catalog:
return ""
lines = [f"- {c['name']}: {c['description']}" for c in catalog]
@@ -92,13 +104,31 @@ def skill_catalog_text(loader: SkillLoader) -> str:
)
def skill_tools(loader: SkillLoader) -> list:
AllowedSkills = Union[set, Callable[[], set], None]
def skill_tools(loader: SkillLoader, allowed: AllowedSkills = None) -> list:
"""`allowed` gates load_skill: a set is a build-time snapshot; a CALLABLE is consulted
on every call the manager passes one so Settings disables apply to live sessions
immediately, and skills created after the engine was built are still loadable
(loader rescans on a miss)."""
def _allowed_now() -> Optional[set]:
return allowed() if callable(allowed) else allowed
def load_skill(name: str) -> dict:
"""Load a skill's full instructions + resources path by name. Call this when a
skill from the catalog is relevant to the current task."""
skill = loader.get(name)
if skill is None:
return {"error": f"unknown skill: {name}", "available": loader.names()}
loader.rescan() # created after this session started? pick it up now
skill = loader.get(name)
gate = _allowed_now()
if skill is None or (gate is not None and name not in gate):
available = sorted(
n for n in loader.names() if gate is None or n in gate
)
return {"error": f"unknown skill: {name}", "available": available}
return {
"name": skill.name,
"instructions": skill.instructions,
+620
View File
@@ -0,0 +1,620 @@
"""Skill management — CRUD over skill folders + per-session mutes (SKILLS-SPEC §4).
Scope = folder location (folder-is-truth): global skills live in ``state_dir()/skills``,
project skills in ``<workspace>/.coworker/skills``. There is no database; every operation
is a folder + ``SKILL.md`` operation, which keeps project skills shareable via git for free.
Disable state is deliberately NOT a marker inside the skill folder: project folders travel
with the repo and one user's disable must not be committed to teammates. It lives in the
personal ``state_dir()/skills-settings.json`` instead.
Uploads are staged (parse preview confirm) so the user always reviews exactly what will
be saved before anything lands in a scope dir. Staged content sits under
``state_dir()/skills-staged/<token>`` until confirmed or discarded.
"""
from __future__ import annotations
import io
import json
import re
import shutil
import threading
import uuid
import zipfile
from pathlib import Path
from typing import Any, Callable, Optional
import aisuite as ai
from ..secrets import state_dir
from .base import Skill, _parse_skill
_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
_MAX_NAME = 64
GLOBAL_SCOPE = "global"
PROJECT_SCOPE = "project"
def validate_name(name: str) -> str:
"""Skill names become folder names — reject anything that could escape the scope dir."""
name = (name or "").strip()
if not name:
raise ValueError("Skill name is required.")
if len(name) > _MAX_NAME:
raise ValueError(f"Skill name too long (limit {_MAX_NAME} characters).")
if ".." in name or "/" in name or "\\" in name or not _NAME_RE.match(name):
raise ValueError(
"Skill name may only contain letters, digits, dots, dashes, and underscores."
)
return name
def _frontmatter_source(md: Path) -> str:
"""Read the optional ``source:`` frontmatter key (``uploaded`` etc.). Absent → created here."""
try:
text = md.read_text(encoding="utf-8")
except OSError:
return ""
if not text.startswith("---"):
return ""
end = text.find("\n---", 3)
if end == -1:
return ""
for line in text[3:end].splitlines():
if ":" in line:
key, value = line.split(":", 1)
if key.strip().lower() == "source":
return value.strip()
return ""
def _write_skill_md(
folder: Path, *, name: str, description: str, instructions: str, source: str = ""
) -> None:
lines = ["---", f"name: {name}", f"description: {description}"]
if source:
lines.append(f"source: {source}")
lines += ["---", "", instructions.strip(), ""]
folder.mkdir(parents=True, exist_ok=True)
(folder / "SKILL.md").write_text("\n".join(lines), encoding="utf-8")
class SkillStore:
"""Folder-backed skill CRUD across the global + project scopes."""
def __init__(self, global_dir: Optional[str | Path] = None) -> None:
self.global_dir = Path(global_dir) if global_dir else state_dir() / "skills"
self._settings_path = state_dir() / "skills-settings.json"
self._staging_dir = state_dir() / "skills-staged"
self._lock = threading.Lock()
# -- scope dirs ---------------------------------------------------------------
def project_dir(self, workspace: str | Path) -> Path:
return Path(workspace).expanduser().resolve() / ".coworker" / "skills"
def _base(self, scope: str, workspace: Optional[str | Path]) -> Path:
if scope == GLOBAL_SCOPE:
return self.global_dir
if scope == PROJECT_SCOPE:
if not workspace:
raise ValueError("A workspace is required for a project-scoped skill.")
ws = Path(workspace).expanduser()
if not ws.is_dir():
raise ValueError(f"Unknown workspace: {workspace}")
return self.project_dir(ws)
raise ValueError(f"Unknown scope: {scope}")
def _folder_of(self, base: Path, name: str) -> Path:
"""The skill's folder, guarded against escaping its scope dir (symlinked folders
that resolve elsewhere are treated as absent rather than followed)."""
folder = base / name
try:
resolved = folder.resolve()
base_resolved = base.resolve()
except OSError:
raise ValueError(f"Unreadable skill folder: {name}")
if base_resolved not in resolved.parents and resolved != base_resolved / name:
raise ValueError(f"Skill folder escapes its scope: {name}")
return folder
# -- queries ------------------------------------------------------------------
def find(
self, name: str, workspace: Optional[str | Path] = None
) -> tuple[Path, str]:
"""Locate a skill by name, most-local first (project before global) — mirrors the
loader's collision precedence so management operates on the copy the model sees."""
name = validate_name(name)
if workspace:
project = self.project_dir(Path(workspace).expanduser())
if (project / name / "SKILL.md").is_file():
return self._folder_of(project, name), PROJECT_SCOPE
if (self.global_dir / name / "SKILL.md").is_file():
return self._folder_of(self.global_dir, name), GLOBAL_SCOPE
raise ValueError(f"Unknown skill: {name}")
def rows(self, workspace: Optional[str | Path] = None) -> list[dict[str, Any]]:
"""Enriched listing for the Settings screen: scope, source, enabled. Global first,
then project (a project row with a colliding name is the effective copy)."""
disabled = self.disabled_names()
out: list[dict[str, Any]] = []
seen: dict[str, int] = {}
scopes: list[tuple[Path, str]] = [(self.global_dir, GLOBAL_SCOPE)]
if workspace:
scopes.append((self.project_dir(Path(workspace).expanduser()), PROJECT_SCOPE))
for base, scope in scopes:
if not base.is_dir():
continue
for sub in sorted(base.iterdir()):
md = sub / "SKILL.md"
if not md.is_file():
continue
skill = _parse_skill(md)
try:
# Bundled resources beyond SKILL.md (§6): a rich skill must not look
# identical to a one-file one in the Settings list.
bundled = sum(1 for p in sub.rglob("*") if p.is_file()) - 1
except OSError:
bundled = 0
row = {
"name": skill.name,
"description": skill.description,
"instructions": skill.instructions, # Settings editor prefill
"scope": scope,
"source": _frontmatter_source(md) or "local",
"enabled": skill.name not in disabled,
"path": str(sub),
"files": max(bundled, 0),
}
if skill.name in seen: # project copy shadows the global one
out[seen[skill.name]] = row
else:
seen[skill.name] = len(out)
out.append(row)
return out
# -- mutations ----------------------------------------------------------------
def create(
self,
*,
name: str,
description: str,
instructions: str,
scope: str = GLOBAL_SCOPE,
workspace: Optional[str | Path] = None,
source: str = "",
) -> dict[str, Any]:
name = validate_name(name)
description = (description or "").strip()
if not (instructions or "").strip():
raise ValueError("Skill instructions are required.")
base = self._base(scope, workspace)
folder = self._folder_of(base, name)
if (folder / "SKILL.md").is_file():
raise ValueError(f"A skill named '{name}' already exists in that scope.")
_write_skill_md(
folder,
name=name,
description=description,
instructions=instructions,
source=source,
)
return {"name": name, "scope": scope, "path": str(folder)}
def update(
self,
name: str,
*,
description: Optional[str] = None,
instructions: Optional[str] = None,
workspace: Optional[str | Path] = None,
) -> dict[str, Any]:
"""Rewrite SKILL.md fields in place; sibling resource files are untouched."""
folder, scope = self.find(name, workspace)
current = _parse_skill(folder / "SKILL.md")
if instructions is not None and not instructions.strip():
raise ValueError("Skill instructions are required.")
_write_skill_md(
folder,
name=current.name,
description=(
description if description is not None else current.description
),
instructions=(
instructions if instructions is not None else current.instructions
),
source=_frontmatter_source(folder / "SKILL.md"),
)
return {"name": current.name, "scope": scope}
def delete(self, name: str, workspace: Optional[str | Path] = None) -> None:
folder, _scope = self.find(name, workspace)
if folder.is_symlink(): # never follow a link out of the scope dir
folder.unlink()
return
shutil.rmtree(folder)
def move(
self,
name: str,
*,
to_scope: str,
workspace: Optional[str | Path] = None,
) -> dict[str, Any]:
folder, from_scope = self.find(name, workspace)
if from_scope == to_scope:
return {"name": name, "scope": to_scope}
target_base = self._base(to_scope, workspace)
target = self._folder_of(target_base, name)
if (target / "SKILL.md").is_file():
raise ValueError(
f"A skill named '{name}' already exists in the target scope."
)
target_base.mkdir(parents=True, exist_ok=True)
shutil.move(str(folder), str(target))
return {"name": name, "scope": to_scope}
# -- enable / disable (personal, survives restarts) -----------------------------
def disabled_names(self) -> set[str]:
try:
data = json.loads(self._settings_path.read_text(encoding="utf-8"))
return {str(n) for n in data.get("disabled", [])}
except (OSError, ValueError):
return set()
def set_enabled(self, name: str, enabled: bool) -> None:
name = validate_name(name)
with self._lock:
disabled = self.disabled_names()
if enabled:
disabled.discard(name)
else:
disabled.add(name)
self._settings_path.parent.mkdir(parents=True, exist_ok=True)
self._settings_path.write_text(
json.dumps({"disabled": sorted(disabled)}, indent=2),
encoding="utf-8",
)
# -- uploads: stage → preview → confirm -----------------------------------------
def stage_upload(self, data: bytes, filename: str = "") -> dict[str, Any]:
"""Stage an upload and return the parsed preview. Accepts a ``.zip`` (folder skill)
or a bare ``SKILL.md`` with YAML frontmatter. Nothing is installed until
:meth:`confirm_upload`. (A ``.skill`` file is a renamed zip and still unpacks
just not advertised.)"""
try:
archive = zipfile.ZipFile(io.BytesIO(data))
except zipfile.BadZipFile:
return self._stage_single_md(data, filename)
# macOS Finder's "Compress" injects __MACOSX/ shadow entries (._*) and .DS_Store —
# metadata, not skill content. Strip them so a Mac-made zip installs clean.
names = [
n
for n in archive.namelist()
if not n.endswith("/")
and "__MACOSX" not in Path(n).parts
and Path(n).name != ".DS_Store"
and not Path(n).name.startswith("._")
]
for entry in names:
p = Path(entry)
if p.is_absolute() or ".." in p.parts or (p.parts and ":" in p.parts[0]):
raise ValueError("Archive contains unsafe paths.")
# SKILL.md at the root, or inside exactly one top-level folder.
md_entries = [n for n in names if Path(n).name == "SKILL.md"]
roots = {Path(n).parts[0] if len(Path(n).parts) > 1 else "" for n in md_entries}
if not md_entries or len(roots) != 1:
raise ValueError("Archive must contain exactly one skill (one SKILL.md).")
root = roots.pop()
token = uuid.uuid4().hex
staged = self._staging_dir / token
staged.mkdir(parents=True, exist_ok=True)
for entry in names:
parts = Path(entry).parts
rel = Path(*parts[1:]) if root and parts[0] == root else Path(entry)
if not str(rel):
continue
target = staged / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(archive.read(entry))
skill = _parse_skill(staged / "SKILL.md")
name = skill.name if skill.name else staged.name
try:
validate_name(name)
except ValueError:
shutil.rmtree(staged, ignore_errors=True)
raise
extras = sorted(
str(p.relative_to(staged))
for p in staged.rglob("*")
if p.is_file() and p.name != "SKILL.md"
)
return {
"token": token,
"name": name,
"description": skill.description,
"instructions": skill.instructions,
"files": extras,
}
def _stage_single_md(self, data: bytes, filename: str) -> dict[str, Any]:
"""The bare-.md path: one SKILL.md, no resources. Frontmatter must carry the name
(there is no folder to fall back to)."""
if filename.lower().endswith((".zip", ".skill")):
raise ValueError("Not a valid .zip archive.")
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
raise ValueError("Not a valid skill file — upload a .zip or a SKILL.md.")
token = uuid.uuid4().hex
staged = self._staging_dir / token
staged.mkdir(parents=True, exist_ok=True)
(staged / "SKILL.md").write_text(text, encoding="utf-8")
skill = _parse_skill(staged / "SKILL.md")
if skill.name == token: # no frontmatter name → parser fell back to the folder
shutil.rmtree(staged, ignore_errors=True)
raise ValueError(
"The .md file needs YAML frontmatter with at least a skill name."
)
try:
validate_name(skill.name)
except ValueError:
shutil.rmtree(staged, ignore_errors=True)
raise
return {
"token": token,
"name": skill.name,
"description": skill.description,
"instructions": skill.instructions,
"files": [],
}
def confirm_upload(
self,
token: str,
*,
scope: str = GLOBAL_SCOPE,
workspace: Optional[str | Path] = None,
) -> dict[str, Any]:
staged = self._staging_dir / str(token)
if not (staged / "SKILL.md").is_file():
raise ValueError("Unknown or expired upload.")
skill = _parse_skill(staged / "SKILL.md")
name = validate_name(skill.name)
base = self._base(scope, workspace)
folder = self._folder_of(base, name)
if (folder / "SKILL.md").is_file():
raise ValueError(f"A skill named '{name}' already exists in that scope.")
base.mkdir(parents=True, exist_ok=True)
shutil.move(str(staged), str(folder))
# Stamp provenance so the Settings screen can distinguish uploaded from local.
if not _frontmatter_source(folder / "SKILL.md"):
_write_skill_md(
folder,
name=name,
description=skill.description,
instructions=skill.instructions,
source="uploaded",
)
return {"name": name, "scope": scope, "path": str(folder)}
def discard_upload(self, token: str) -> None:
staged = self._staging_dir / str(token)
shutil.rmtree(staged, ignore_errors=True)
class SessionSkillStore:
"""``{session_id: {skill: bool}}`` — per-session mutes only; an absent entry means the
session inherits (enabled unless disabled in Settings). Mirrors SessionConnectionStore."""
def __init__(self, path: Optional[str | Path] = None) -> None:
self.path = Path(path) if path else None
self._lock = threading.Lock()
self._rows: dict[str, dict[str, bool]] = {}
self._load()
def _load(self) -> None:
if self.path and self.path.is_file():
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return
self._rows = {
sid: {str(s): bool(v) for s, v in (row or {}).items()}
for sid, row in data.get("sessions", {}).items()
}
def _save(self) -> None:
if not self.path:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(
json.dumps({"sessions": self._rows}, indent=2), encoding="utf-8"
)
def get(self, session_id: str) -> dict[str, bool]:
return dict(self._rows.get(session_id, {}))
def set(self, session_id: str, skill: str, enabled: bool) -> None:
with self._lock:
self._rows.setdefault(session_id, {})[skill] = bool(enabled)
self._save()
def clear(self, session_id: str, skill: str) -> None:
with self._lock:
row = self._rows.get(session_id)
if row and skill in row:
del row[skill]
if not row:
del self._rows[session_id]
self._save()
def remove_session(self, session_id: str) -> None:
with self._lock:
if session_id in self._rows:
del self._rows[session_id]
self._save()
def effective_skills(
*,
names: set[str],
disabled: set[str],
session_overrides: dict[str, bool],
) -> set[str]:
"""The single source of truth for a session's skill menu (SKILLS-SPEC §3): any-off-wins.
A Settings disable removes the skill everywhere a session override can NOT resurrect
it. Absent any opinion, a skill is on."""
out: set[str] = set()
for name in names:
if name in disabled:
continue
if not session_overrides.get(name, True):
continue
out.add(name)
return out
# -- the worker-authors door (SKILLS-SPEC §5.2) -------------------------------------
_SAVE_SKILL_SCHEMA = {
"type": "function",
"function": {
"name": "save_skill",
"description": (
"Propose adding a finished skill to the user's skills. The user reviews the "
"name, description, full instructions, and any bundled files on an approval "
"card before anything is saved; once they approve, the skill is usable in "
"every conversation. Use this after building or refining a skill in "
"conversation, and offer it in words like: 'Want me to add <name> to your "
"skills?' — say 'your skills', never the app name; say 'add', never "
"'install'. If a skill with this name already exists, approving overwrites "
"its instructions and adds the files."
),
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Short folder-safe skill name (letters, digits, dots, dashes, underscores).",
},
"description": {
"type": "string",
"description": "One line saying when the skill applies — this is its menu entry.",
},
"instructions": {
"type": "string",
"description": "The full instruction body (markdown). Becomes SKILL.md.",
},
"files": {
"type": "array",
"items": {"type": "string"},
"description": (
"Optional paths of files in this session's folders to bundle into "
"the skill (scripts, examples, README). Copied in by basename."
),
},
},
"required": ["name", "description", "instructions"],
},
},
}
def save_skill_tool(
store: Optional[SkillStore] = None,
*,
allowed_dirs: Optional[list[str | Path]] = None,
) -> Callable:
"""Build the `save_skill` tool (SKILLS-SPEC §5.2). `requires_approval=True` routes every
call through the standard approval card the tool's ARGUMENTS are the review surface,
which is why the schema carries the full instructions and file list. Bundled files may
only be read from `allowed_dirs` (the session's roots): the worker must never bundle
arbitrary machine paths into a skill."""
store = store or SkillStore()
dirs: list[Path] = []
for d in allowed_dirs or []:
try:
dirs.append(Path(d).expanduser().resolve())
except OSError:
continue
def save_skill(
name: str,
description: str = "",
instructions: str = "",
files: Optional[list[str]] = None,
) -> dict[str, Any]:
try:
name = validate_name(name)
except ValueError as exc:
return {"error": str(exc)}
if not (description or "").strip():
return {"error": "A one-line description is required — it becomes the skill's menu entry."}
if not (instructions or "").strip():
return {"error": "Skill instructions are required."}
# Resolve + vet the bundle BEFORE touching disk, so a bad file never leaves a
# half-written skill behind.
staged: list[tuple[Path, str]] = []
for raw in files or []:
p = Path(str(raw)).expanduser()
if not p.is_absolute():
if not dirs:
return {"error": f"File is outside this session's folders: {raw}"}
p = dirs[0] / p
try:
rp = p.resolve()
except OSError:
return {"error": f"Unreadable file: {raw}"}
if not rp.is_file():
return {"error": f"Not a file: {raw}"}
if not any(d == rp or d in rp.parents for d in dirs):
return {"error": f"File is outside this session's folders: {raw}"}
base = rp.name
if base.lower() == "skill.md":
# The instructions argument BECOMES SKILL.md; models routinely draft one in
# the workspace and bundle it. Skip silently — erroring here cost the user a
# second approval round for a self-healing retry (live drive 2026-07-27).
continue
if any(base == b for _, b in staged):
return {"error": f"Duplicate bundled filename: {base}"}
staged.append((rp, base))
# Worker-authored skills always land GLOBAL (§3.4: never a throwaway location).
try:
folder, _scope = store.find(name)
action = "updated"
store.update(name, description=description.strip(), instructions=instructions)
except ValueError:
action = "added"
created = store.create(
name=name, description=description.strip(), instructions=instructions
)
folder = Path(created["path"])
for src, base in staged:
shutil.copy2(src, folder / base)
return {
"ok": True,
"name": name,
"action": action,
"files": [b for _, b in staged],
"note": (
"Saved to the user's skills — usable in every conversation from now on. "
"Confirm in one short sentence. To browse the installed files, point the "
"user to Settings > Skills (the file-count chip opens the folder) — do NOT "
"link the workspace build folder as an artifact; folders don't open there."
),
}
save_skill.__name__ = "save_skill"
save_skill.__doc__ = _SAVE_SKILL_SCHEMA["function"]["description"]
save_skill.__aisuite_tool_metadata__ = ai.ToolMetadata(
name="save_skill",
category="skills",
risk_level="medium",
capabilities=["save_skill"],
requires_approval=True,
)
save_skill.__coworker_schema__ = _SAVE_SKILL_SCHEMA
return save_skill
+1 -1
View File
@@ -45,7 +45,7 @@ test("run_shell → full card: description title, command preview, stays-on-this
// The mocked proposal has no description → plain "Run a command" title; the command is
// the preview; the reason still renders; the scope note replaces the old badge.
await expect(page.getByText("Run a command").last()).toBeVisible();
await expect(page.getByText("stays on this Mac").last()).toBeVisible();
await expect(page.getByText("stays on this computer").last()).toBeVisible();
await expect(page.getByText("The coworker wants to run a command.").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Always allow this command" }).last()).toBeVisible();
await expect(page.getByText(/local action/)).toHaveCount(0);
+84 -3
View File
@@ -158,7 +158,7 @@ const CONNECTORS = {
// MCP-BACKED connectors (§42): vendor-hosted MCP + local OAuth, pinned tool subset.
// monday is one-click ONLY (no manual fields); jira also has a manual token path
// (two-mode modal). Neither needs cloud sign-in.
{ name: "monday", title: "monday.com", icon: "▦", blurb: "Read boards and items, track work, create items and post updates.", aliases: ["project management", "tasks", "boards"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#6161ff", logo: "monday", mcp: true, fields: [], instructions: ["One click connects via monday.com sign-in in your browser.", "Sign-in is fully local — tokens stay on this Mac."], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "mcp__monday__get_board_info", label: "Read board", kind: "read", description: "Read a board's columns and groups.", enabled: true, requires_approval: false }, { name: "mcp__monday__create_item", label: "Create item", kind: "write", description: "Create an item on a board.", enabled: true, requires_approval: true }], managed: false, managed_profile: false },
{ name: "monday", title: "monday.com", icon: "▦", blurb: "Read boards and items, track work, create items and post updates.", aliases: ["project management", "tasks", "boards"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#6161ff", logo: "monday", mcp: true, fields: [], instructions: ["One click connects via monday.com sign-in in your browser.", "Sign-in is fully local — tokens stay on this computer."], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "mcp__monday__get_board_info", label: "Read board", kind: "read", description: "Read a board's columns and groups.", enabled: true, requires_approval: false }, { name: "mcp__monday__create_item", label: "Create item", kind: "write", description: "Create an item on a board.", enabled: true, requires_approval: true }], managed: false, managed_profile: false },
{ name: "jira", title: "Jira", icon: "◆", blurb: "Search, summarize, create, and update issues.", aliases: ["issues", "tickets", "atlassian"], auth: "api_token", two_way: false, channels: false, available: true, brand_color: "#0052cc", logo: "jira", mcp: true, fields: [{ key: "base_url", label: "Atlassian site URL", secret: false, required: true, help: "", placeholder: "" }, { key: "email", label: "Account email", secret: false, required: true, help: "", placeholder: "" }, { key: "api_token", label: "API token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: false, managed_profile: false },
],
};
@@ -552,6 +552,14 @@ export async function mockApi(page: import("@playwright/test").Page) {
// Per-session unattended flag — mutable so the composer's "Send to Inbox" toggle persists and
// the app reads it back (which is what gates parking approvals to the Inbox vs an inline card).
const unattended: Record<string, boolean> = {};
// Skills (SKILLS-SPEC) — mutable folder-is-truth mirror: Settings CRUD, the enabled flag,
// staged uploads (stage → preview → confirm), and the composer's per-session menu all
// round-trip through this one list.
const skills: any[] = [
{ name: "weekly-report", description: "Monday status report", instructions: "1. Collect updates\n2. Write it up", scope: "global", source: "local", enabled: true, path: "/state/skills/weekly-report", files: 0 },
{ name: "html-to-markdown", description: "Convert an HTML document or fragment to clean markdown.", instructions: "Convert the given HTML to markdown, preserving structure.", scope: "global", source: "uploaded", enabled: true, path: "/state/skills/html-to-markdown", files: 2 },
];
let stagedSkill: any = null;
// Fresh cloud sign-in state per test (module state outlives a page).
Object.assign(CLOUD_STATE, {
@@ -585,7 +593,12 @@ export async function mockApi(page: import("@playwright/test").Page) {
const msg = JSON.parse(String(raw));
if (msg.type === "user_message") {
hadTurn = true;
send("turn_start", { input: msg.text });
// Force-run (SKILLS-SPEC §6): like the real server, TURN_START ships the user's
// literal "/name …" line as `display` so the client dedupes on what the user sees.
send("turn_start", {
input: msg.text,
...(msg.skill ? { display: `/${msg.skill}${msg.text ? ` ${msg.text}` : ""}` } : {}),
});
if (/run a tool/i.test(msg.text)) {
pendingTool = "run_shell";
send("tool_proposed", { name: "run_shell", arguments: { command: "ls" } });
@@ -721,10 +734,11 @@ export async function mockApi(page: import("@playwright/test").Page) {
send("assistant_delta", { text: msg.text });
// Echo the model the message carried — pins the model-per-message contract (the
// composer's visible model must ride on every user_message; 2026-07-04 fix).
// Same for `skill`: the force-run pick must ride as its OWN FIELD, never as text.
// `usage` mirrors the real engine's assistant_message sidecar (OPE-42): fixed
// counts per turn so the usage-chip specs can assert exact accumulation.
send("assistant_message", {
text: `Echo: ${msg.text} [model=${msg.model || "none"}]`,
text: `Echo: ${msg.text} [model=${msg.model || "none"}]${msg.skill ? ` [skill=${msg.skill}]` : ""}`,
usage: {
model: msg.model || "anthropic:claude-opus-4-8",
input: 1_000,
@@ -836,6 +850,73 @@ export async function mockApi(page: import("@playwright/test").Page) {
return json(i >= 0 ? sessions[i] : PINNED_SESSION);
}
// Skills (SKILLS-SPEC §5/§6). Order matters: upload/confirm before the {name} regexes.
if (/\/v1\/sessions\/[^/]+\/skills$/.test(p)) {
// The composer's live menu: every Settings-enabled skill (§4 — disabled = invisible).
return json({
skills: skills
.filter((s) => s.enabled)
.map((s) => ({ name: s.name, description: s.description, scope: s.scope, enabled: true })),
});
}
if (p.endsWith("/v1/skills/upload/confirm") && m === "POST") {
const b = req.postDataJSON() || {};
if (!stagedSkill || b.token !== stagedSkill.token)
return json({ ok: false, error: "Upload expired — pick the file again." });
skills.push({
name: stagedSkill.name, description: stagedSkill.description,
instructions: stagedSkill.instructions, scope: "global", source: "uploaded",
enabled: true, path: `/state/skills/${stagedSkill.name}`, files: stagedSkill.files.length,
});
stagedSkill = null;
return json({ ok: true });
}
if (p.endsWith("/v1/skills/upload") && m === "POST") {
// Stage → preview; nothing lands until confirm. Fixed parse (the mock reads no zips).
stagedSkill = {
token: "stage-1", name: "greet", description: "says hello",
instructions: "Say hello warmly.", files: ["notes.txt"],
};
return json({ ok: true, ...stagedSkill });
}
{
const mr = p.match(/\/v1\/skills\/([^/]+)\/reveal$/);
if (mr && m === "POST") {
return json(
skills.some((s) => s.name === decodeURIComponent(mr[1]))
? { ok: true }
: { ok: false, error: `Unknown skill: ${decodeURIComponent(mr[1])}` },
);
}
}
if (/\/v1\/skills\/[^/]+$/.test(p) && (m === "PATCH" || m === "DELETE")) {
const name = decodeURIComponent(p.split("/").pop()!);
const i = skills.findIndex((s) => s.name === name);
if (i < 0) return json({ ok: false, error: `Unknown skill: ${name}` });
if (m === "DELETE") {
skills.splice(i, 1);
return json({ ok: true });
}
const b = req.postDataJSON() || {};
if (typeof b.enabled === "boolean") skills[i].enabled = b.enabled;
if (typeof b.description === "string") skills[i].description = b.description;
if (typeof b.instructions === "string") skills[i].instructions = b.instructions;
return json({ ok: true });
}
if (p.endsWith("/v1/skills") && m === "POST") {
const b = req.postDataJSON() || {};
if (!b.name || !(b.instructions || "").trim())
return json({ ok: false, error: "Skill name and instructions are required." });
if (skills.some((s) => s.name === b.name))
return json({ ok: false, error: `A skill named '${b.name}' already exists in that scope.` });
skills.push({
name: b.name, description: b.description || "", instructions: b.instructions,
scope: "global", source: "local", enabled: true, path: `/state/skills/${b.name}`, files: 0,
});
return json({ ok: true });
}
if (p.endsWith("/v1/skills")) return json({ skills });
if (p.endsWith("/v1/health")) return json(HEALTH);
if (p.endsWith("/v1/settings")) return json(SETTINGS);
if (p.endsWith("/v1/settings/context-bar") && m === "POST") {
+30
View File
@@ -0,0 +1,30 @@
import { test, expect } from "./fixtures";
// SKILLS-SPEC §9 journey 4 — the "/" force-run: popup pick inserts the inline `/name `
// prefix, the send carries the skill as its OWN WebSocket field (never as message text),
// and the transcript shows ONE truthful bubble with exactly what the user typed.
test("skills-forcerun: popup pick → inline /name → skill rides the frame → one bubble", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
// "/" opens the popup; picking inserts the inline prefix (no chip) and keeps focus.
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("/");
await expect(page.getByTestId("skill-popup")).toBeVisible();
await page.getByText("/weekly-report").click();
await expect(box).toHaveValue("/weekly-report ");
await box.type("cover last week");
await box.press("Enter");
// ONE user bubble, showing the literal line the user typed — never the model-facing
// "load this skill…" framing (§6: the _display contract).
await expect(page.getByText("/weekly-report cover last week")).toHaveCount(1);
await expect(page.getByText(/Use the skill/)).toHaveCount(0);
// The fake agent echoes what actually rode the wire: text WITHOUT the prefix, and the
// skill as its own field.
await expect(page.getByText(/\[skill=weekly-report\]/)).toBeVisible();
await expect(page.getByText(/Echo: cover last week/)).toBeVisible();
});
+39
View File
@@ -0,0 +1,39 @@
import { test, expect } from "./fixtures";
// SKILLS-SPEC §9 journey 2 — liveness from the session's seat: the composer's "/" popup is
// the live "what can my worker use right now" view. A skill created in Settings is offered;
// a disabled one vanishes. Hermetic: the popup reads /v1/sessions/{id}/skills from fixtures.
test("skills-session: new skill offered in '/', disabled one absent", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
// The seeded menu: both enabled skills offered on "/".
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("/");
await expect(page.getByTestId("skill-popup")).toBeVisible();
await expect(page.getByText("/weekly-report")).toBeVisible();
await expect(page.getByText("/html-to-markdown")).toBeVisible();
await box.fill(""); // close the popup
// Settings round-trip: create one skill, disable another.
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Skills", exact: true }).click();
await page.getByRole("button", { name: /Add skill/ }).click();
await page.getByText("Write it myself").click();
await page.getByLabel("Name").fill("fresh-skill");
await page.getByLabel("Instructions").fill("Do the fresh thing.");
await page.getByRole("button", { name: "Save skill" }).click();
await expect(page.getByRole("status")).toContainText("fresh-skill");
await page.getByLabel("weekly-report enabled").click();
await expect(page.getByRole("status")).toContainText("turned off everywhere");
// Back in the session: the popup reflects the new state — created offered, disabled gone.
await page.getByText("Draft the launch note").first().click();
await box.fill("/");
await expect(page.getByTestId("skill-popup")).toBeVisible();
await expect(page.getByText("/fresh-skill")).toBeVisible();
await expect(page.getByText("/weekly-report")).toHaveCount(0);
await expect(page.getByText("/html-to-markdown")).toBeVisible(); // untouched one persists
});
+65
View File
@@ -0,0 +1,65 @@
import { test, expect } from "./fixtures";
// SKILLS-SPEC §9 journey 1 — Settings ▸ Skills as the management home: create through the
// Add-skill menu, edit in place, disable with the amber clean-slate banner, and the
// rich-skill folder chip. Hermetic: every /v1 call lands in fixtures.ts.
const openSkills = async (page: import("@playwright/test").Page) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Skills", exact: true }).click();
};
test("skills-settings: create via the menu → name-first banner; edit persists", async ({ page }) => {
await openSkills(page);
// The seeded rows render; the rich one wears its folder chip; the list is the page
// (no standing add-surfaces).
await expect(page.getByText("weekly-report")).toBeVisible();
await expect(page.getByText("uploaded")).toBeVisible();
await expect(page.getByTitle("Show folder")).toContainText("2 files");
await expect(page.getByText("Start a conversation")).toHaveCount(0);
// Add skill ▾ → the three doors, then Write it myself.
await page.getByRole("button", { name: /Add skill/ }).click();
await expect(page.getByText("Import a file")).toBeVisible();
await expect(page.getByText("Create with OpenWorker")).toBeVisible();
await page.getByText("Write it myself").click();
await page.getByLabel("Name").fill("greet-warmly");
await page.getByLabel("Description").fill("Greets people warmly");
await page.getByLabel("Instructions").fill("Always greet warmly.");
await page.getByRole("button", { name: "Save skill" }).click();
// Name-first teal confirmation (§7) + the new row.
const status = page.getByRole("status");
await expect(status).toContainText("greet-warmly");
await expect(status).toContainText("can now use it in every conversation");
await expect(page.getByText("Greets people warmly")).toBeVisible();
// Edit: pencil prefills, name locked, save PATCHes through to the re-fetched list.
await page.getByTitle("Edit").first().click();
const name = page.getByLabel("Name");
await expect(name).toBeDisabled();
await page.getByLabel("Description").fill("Monday status report, sharper");
await page.getByRole("button", { name: "Save skill" }).click();
await expect(page.getByText("Monday status report, sharper")).toBeVisible();
});
test("skills-settings: disable → amber everywhere/clean-slate banner; delete is two-step", async ({ page }) => {
await openSkills(page);
await page.getByLabel("weekly-report enabled").click();
const status = page.getByRole("status");
await expect(status).toContainText("weekly-report");
await expect(status).toContainText("turned off everywhere");
await expect(status).toContainText("start a new one for a completely clean slate");
// Two-step delete: arm, confirm, row gone, banner names the skill.
await page.getByLabel("Delete html-to-markdown").click();
await expect(page.getByText("html-to-markdown")).toBeVisible(); // armed ≠ deleted
await page.getByText("Confirm delete").click();
await expect(page.getByText("html-to-markdown")).toHaveCount(1); // only the banner remains
await expect(page.getByRole("status")).toContainText("removed");
});
+37
View File
@@ -0,0 +1,37 @@
import { test, expect } from "./fixtures";
// SKILLS-SPEC §9 journey 3 — import with the mandatory review gate: the preview installs
// NOTHING; confirm installs and the row wears the `uploaded` provenance badge. Hermetic:
// stage/confirm round-trip through fixtures.ts state.
test("skills-upload: preview installs nothing → confirm → uploaded badge", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Skills", exact: true }).click();
// Add skill ▾ → Import a file → straight to the (hidden) picker.
await page.getByRole("button", { name: /Add skill/ }).click();
await page.getByText("Import a file").click();
await page.getByLabel("Upload a skill archive").setInputFiles({
name: "greet.zip",
mimeType: "application/zip",
buffer: Buffer.from("PKfake"),
});
// The mandatory review screen: everything parsed, nothing installed yet.
await expect(page.getByText("Review before installing")).toBeVisible();
await expect(page.getByText("says hello")).toBeVisible();
await expect(page.getByText("Say hello warmly.")).toBeVisible();
await expect(page.getByText(/notes\.txt/)).toBeVisible();
await expect(page.getByText("greet", { exact: true })).toHaveCount(1); // preview only, no row
await page.getByRole("button", { name: "Install skill" }).click();
// Installed: teal name-first banner, a real row with the provenance badge + folder chip.
const status = page.getByRole("status");
await expect(status).toContainText("greet");
await expect(status).toContainText("can now use it in every conversation");
await expect(page.getByText("greet", { exact: true })).toHaveCount(2); // banner + the new row
await expect(page.getByText("uploaded")).toHaveCount(2); // html-to-markdown + greet
});
+29 -9
View File
@@ -211,10 +211,12 @@ export function App() {
const [scheduledOpenId, setScheduledOpenId] = useState<string | null>(null);
const [gateCreate, setGateCreate] = useState(false);
// Which Settings section the full-page Settings surface opens on (§ Settings-as-page).
const [settingsTab, setSettingsTab] = useState<"appearance" | "models" | "voice" | "personas">(
"appearance",
);
const openSettings = (tab: "appearance" | "models" | "voice" | "personas" = "appearance") => {
const [settingsTab, setSettingsTab] = useState<
"appearance" | "models" | "skills" | "voice" | "personas"
>("appearance");
const openSettings = (
tab: "appearance" | "models" | "skills" | "voice" | "personas" = "appearance",
) => {
setSettingsTab(tab);
setSurface("settings");
};
@@ -613,11 +615,14 @@ export function App() {
: [...p, { kind: "connector", source: src }];
});
} else if (typeof d.input === "string" && d.input) {
// `display` (force-run) is the user's literal "/name …" line; the framed
// `input` is model-facing. Surface/dedupe on what the user actually sees.
const shown = (typeof d.display === "string" && d.display) || (d.input as string);
setItems((p) => {
const last = p[p.length - 1];
return last && last.kind === "user" && last.text === d.input
return last && last.kind === "user" && last.text === shown
? p
: [...p, { kind: "user", text: d.input as string, ts: Date.now() / 1000 }];
: [...p, { kind: "user", text: shown, ts: Date.now() / 1000 }];
});
}
break;
@@ -861,10 +866,13 @@ export function App() {
return () => clearInterval(t);
}, [surface, sessionId, browserRefreshKey, markUnattended]);
const send = (text: string, attachments?: Attachment[]) => {
setItems((p) => [...p, { kind: "user", text, attachments, ts: Date.now() / 1000 }]);
const send = (text: string, attachments?: Attachment[], skill?: string) => {
// Force-run shows exactly what the user typed: "/name rest". Must match the server's
// `display` sidecar formula so the turn_start dedupe recognizes the local echo.
const shown = skill ? `/${skill}${text ? ` ${text}` : ""}` : text;
setItems((p) => [...p, { kind: "user", text: shown, attachments, ts: Date.now() / 1000 }]);
// The visible model rides along with the message (single source of truth per turn).
sessionRef.current?.userMessage(text, attachments, model);
sessionRef.current?.userMessage(text, attachments, model, skill);
followLatest(); // sending always re-engages stream-following, wherever the user had scrolled
};
// Resolving a LIVE prompt also resolves its parked Inbox mirror server-side, but the polled
@@ -1349,6 +1357,17 @@ export function App() {
key={settingsTab}
initialTab={settingsTab}
onOpenPersona={(id) => openPersona(id, "settings")}
onCreateSkill={(description) => {
// The Skills doorway (SKILLS-SPEC §5.2): creation is a conversation. Fresh
// session, description in the composer — the user reads and hits send. With
// no description, the prefill invites them to finish the sentence there.
startNewSession();
prefillComposer(
description
? `Build a new skill for me: ${description}`
: "Build a new skill for me: (describe what the skill should do)",
);
}}
/>
) : surface === "audit" ? (
<AuditView />
@@ -1584,6 +1603,7 @@ export function App() {
onInterrupt={interrupt}
onModeChange={changeMode}
onModelChange={changeModel}
sessionId={sessionId}
workspace={needsWorkspace(agent) ? workspace || "" : undefined}
unattended={unattended}
onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined}
+141 -1
View File
@@ -195,6 +195,8 @@ export interface ArtifactContent {
content?: string;
data_url?: string;
truncated?: boolean;
// kind === "folder": a directory listing (models sometimes link a whole package dir).
entries?: { name: string; dir: boolean; size: number }[];
}
export async function getArtifacts(sessionId: string): Promise<ArtifactInfo[]> {
@@ -1089,6 +1091,141 @@ export async function setSessionConnection(
return res.json();
}
// -- Skills (SKILLS-SPEC §4) ----------------------------------------------------
// Scope = folder location: "global" (every session) or "project" (one workspace).
// The session endpoints resolve the effective menu (Settings disables + session mutes).
export interface SkillRow {
name: string;
description: string;
instructions: string;
scope: "global" | "project";
source: string; // "local" | "uploaded"
enabled: boolean;
path: string;
files?: number; // bundled resources beyond SKILL.md (§6 — rich skills are visible)
}
export interface SessionSkillRow {
name: string;
description: string;
scope: "global" | "project";
enabled: boolean; // false = muted for this session only
}
export interface SkillUploadPreview {
ok: boolean;
error?: string;
token?: string;
name?: string;
description?: string;
instructions?: string;
files?: string[];
}
const skillUrl = (path = "") => `${httpBase()}/v1/skills${path}`;
const jsonPost = (body: unknown, method = "POST") => ({
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
export async function listSkills(workspace?: string): Promise<SkillRow[]> {
const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
const res = await fetch(skillUrl(qs));
return (await res.json()).skills ?? [];
}
export async function createSkill(body: {
name: string;
description: string;
instructions: string;
scope?: "global" | "project";
workspace?: string;
}): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(skillUrl(), jsonPost(body));
return res.json();
}
export async function updateSkill(
name: string,
patch: { description?: string; instructions?: string; enabled?: boolean; workspace?: string },
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}`), jsonPost(patch, "PATCH"));
return res.json();
}
export async function revealSkill(name: string): Promise<{ ok: boolean; error?: string }> {
// §6 "Show folder": the backend opens the skill's folder in the OS file manager.
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}/reveal`), jsonPost({}));
return res.json();
}
export async function deleteSkill(
name: string,
workspace?: string,
): Promise<{ ok: boolean; error?: string }> {
const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}${qs}`), { method: "DELETE" });
return res.json();
}
export async function moveSkill(
name: string,
scope: "global" | "project",
workspace?: string,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}/move`), jsonPost({ scope, workspace }));
return res.json();
}
export async function stageSkillUpload(
dataB64: string,
filename = "",
): Promise<SkillUploadPreview> {
const res = await fetch(skillUrl("/upload"), jsonPost({ data_b64: dataB64, filename }));
return res.json();
}
export async function confirmSkillUpload(
token: string,
scope: "global" | "project" = "global",
workspace?: string,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(skillUrl("/upload/confirm"), jsonPost({ token, scope, workspace }));
return res.json();
}
export async function sessionSkills(
sessionId: string,
workspace?: string,
): Promise<SessionSkillRow[]> {
const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
const res = await fetch(
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/skills${qs}`,
);
return (await res.json()).skills ?? [];
}
export async function setSessionSkill(
sessionId: string,
skill: string,
enabled: boolean,
opts: { clear?: boolean; workspace?: string } = {},
): Promise<{ skills?: SessionSkillRow[]; ok?: boolean; error?: string }> {
const res = await fetch(
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/skills`,
jsonPost({
skill,
enabled,
...(opts.clear ? { clear: true } : {}),
...(opts.workspace ? { workspace: opts.workspace } : {}),
}),
);
return res.json();
}
// -- Inbox + Unattended -------------------------------------------------------
export interface InboxItem {
id: string;
@@ -1846,12 +1983,15 @@ export class Session {
* exactly what the user sees immune to set_model races across reconnects (a new cowork
* session always reconnects once to adopt its scratch dir, which could drop a queued
* set_model and leave the engine on a stale/resumed model; found 2026-07-04). */
userMessage(text: string, attachments?: unknown[], model?: string) {
userMessage(text: string, attachments?: unknown[], model?: string, skill?: string) {
this.send({
type: "user_message",
text,
...(model ? { model } : {}),
...(attachments?.length ? { attachments } : {}),
// Force-run (SKILLS-SPEC §4.1): the composer's /skill pick rides as its own field;
// the server validates it against the session's effective menu and frames the turn.
...(skill ? { skill } : {}),
});
}
@@ -221,7 +221,7 @@ export function AccessSection({
: roots.length > 0
? `${roots.length} folder${roots.length === 1 ? "" : "s"}`
: null;
const summary = folderPart ? `${sourcesPart} · ${folderPart}` : sourcesPart;
const summary = [sourcesPart, folderPart].filter(Boolean).join(" · ");
return (
<section className="rail-section" ref={rootEl} data-testid="access-section">
@@ -383,6 +383,14 @@ export function AccessSection({
+ Add a source
</button>
)}
{/* Lives with its list (tester ask 2026-07-26): each group's manage link sits
directly under that group, not pooled at the section's bottom. */}
<button
className="mt-1.5 block text-[12px] text-accent font-medium hover:underline text-left"
onClick={() => onOpenIntegrations?.()}
>
Manage all connectors (global)
</button>
</div>
{recommended.length > 0 && (
@@ -456,13 +464,6 @@ export function AccessSection({
)}
{rootsError && <div className="roots-err">{rootsError}</div>}
</div>
<button
className="text-[12px] text-accent font-medium hover:underline text-left"
onClick={() => onOpenIntegrations?.()}
>
Manage all connectors (global)
</button>
</div>
)}
</div>
@@ -110,7 +110,7 @@ describe("ApprovalCard — §35 shapes", () => {
expect(onApprove).toHaveBeenCalledWith("once");
});
it("send_file gets the full external card: destination title, file chip, leaves-the-Mac note", () => {
it("send_file gets the full external card: destination title, file chip, leaves-the-computer note", () => {
render(
<ApprovalCard
item={sendApproval({
@@ -121,7 +121,7 @@ describe("ApprovalCard — §35 shapes", () => {
/>,
);
expect(screen.getByText(/Send a file to/).textContent).toContain("C9");
expect(screen.getByText(/leaves this Mac → Slack/)).toBeTruthy();
expect(screen.getByText(/leaves this computer → Slack/)).toBeTruthy();
expect(screen.getByText(/report\.pdf/)).toBeTruthy();
expect(screen.getByText(/here you go/)).toBeTruthy();
expect(screen.getByText("Allow once")).toBeTruthy();
@@ -159,7 +159,7 @@ describe("ApprovalCard — §35 shapes", () => {
);
expect(screen.getByText(/Run a command — fetch semiconductor stock data/)).toBeTruthy();
expect(screen.getByText(/python3 fetch\.py/)).toBeTruthy();
expect(screen.getByText(/stays on this Mac/)).toBeTruthy();
expect(screen.getByText(/stays on this computer/)).toBeTruthy();
expect(screen.getByText("Always allow this command")).toBeTruthy();
});
});
@@ -213,10 +213,94 @@ describe("InboxItemCard — Allow every time on parked run approvals", () => {
expect(screen.getByText("fetch_data.py")).toBeTruthy();
expect(screen.queryByText("Run `send_message`?")).toBeNull();
expect(screen.getByText(/import json/)).toBeTruthy();
expect(screen.getByText(/stays on this Mac/)).toBeTruthy();
expect(screen.getByText(/stays on this computer/)).toBeTruthy();
// §35 labels; resolution vocabulary unchanged (works on every approver path).
fireEvent.click(screen.getByText("Allow once"));
expect(onResolve).toHaveBeenCalledWith("i1", "allow");
// Old rows without tool data keep the legacy treatment (covered above).
});
});
describe("ApprovalCard — save_skill (SKILLS-SPEC §5.2)", () => {
const skillApproval = (extra: Partial<ApprovalItem> = {}): ApprovalItem =>
sendApproval({
name: "save_skill",
category: "skills",
args: {
name: "weekly-github-report",
description: "Create a concise Monday status report from GitHub activity.",
instructions: "1. Fetch PRs\n2. Write the report",
files: ["fetch_prs.py", "sub/example-report.md"],
},
standingTarget: undefined,
...extra,
});
it("shows name-first title, description, instructions, and every bundled file", () => {
render(<ApprovalCard item={skillApproval()} onApprove={vi.fn()} />);
expect(screen.getByText("weekly-github-report")).toBeTruthy(); // bold obj in the title
expect(screen.getAllByText(/to your skills/).length).toBeGreaterThan(0); // title + footer
// The corner answers WHERE; the footer answers what approving means (§5.2 review round).
expect(screen.getByText("saves to Settings ▸ Skills")).toBeTruthy();
expect(screen.getByText(/usable in every conversation from\s+then on/)).toBeTruthy();
expect(
screen.getByText("Create a concise Monday status report from GitHub activity."),
).toBeTruthy();
expect(screen.getByText(/Fetch PRs/)).toBeTruthy();
const chips = screen.getByTestId("skill-bundle-files");
expect(chips.textContent).toContain("fetch_prs.py");
expect(chips.textContent).toContain("example-report.md"); // basename, not the path
});
it("uses the §7 button copy and never offers a session-wide always", () => {
const onApprove = vi.fn();
render(<ApprovalCard item={skillApproval()} onApprove={onApprove} />);
expect(screen.queryByText("Always allow")).toBeNull(); // every proposal gets its own review
expect(screen.queryByText("Deny")).toBeNull();
fireEvent.click(screen.getByText("Add to my skills"));
expect(onApprove).toHaveBeenCalledWith("once");
fireEvent.click(screen.getByText("Not now"));
expect(onApprove).toHaveBeenCalledWith("deny");
});
});
describe("InboxItemCard — parked save_skill proposals (SKILLS-SPEC §5.2)", () => {
const parked = (): InboxItem => ({
id: "i9",
session_id: "s1",
kind: "approval",
title: "Run `save_skill`?",
body: "",
state: "pending",
resolution: null,
inbox: "default",
created_at: "",
resolved_at: null,
data: {
tool: "save_skill",
arguments: {
name: "weekly-github-report",
description: "Create a concise Monday status report from GitHub activity.",
instructions: "1. Fetch PRs\n2. Write the report",
files: ["fetch_prs.py"],
},
},
});
it("wears the same review surface and button copy as the live card", () => {
const onResolve = vi.fn();
render(<InboxItemCard item={parked()} onResolve={onResolve} />);
expect(screen.getByText("saves to Settings ▸ Skills")).toBeTruthy();
expect(
screen.getByText("Create a concise Monday status report from GitHub activity."),
).toBeTruthy();
expect(screen.getByText(/Fetch PRs/)).toBeTruthy();
expect(screen.getByTestId("skill-bundle-files").textContent).toContain("fetch_prs.py");
expect(screen.getByText(/usable in every conversation/)).toBeTruthy();
expect(screen.queryByText("Allow once")).toBeNull();
fireEvent.click(screen.getByText("Add to my skills"));
expect(onResolve).toHaveBeenCalledWith("i9", "allow");
fireEvent.click(screen.getByText("Not now"));
expect(onResolve).toHaveBeenCalledWith("i9", "deny");
});
});
+58 -6
View File
@@ -32,6 +32,43 @@ const EXTERNAL = new Set(["send_message", "send_file"]);
type ApprovalItem = Extract<Item, { kind: "approval" }>;
// Per-tool button copy (§7): a skill proposal is an "add", not an "allow". Shared with the
// parked Inbox card so both dialects match.
export function approvalActionLabels(name?: string): { allow: string; deny: string } {
return name === "save_skill"
? { allow: "Add to my skills", deny: "Not now" }
: { allow: "Allow once", deny: "Deny" };
}
// save_skill's review surface (SKILLS-SPEC §5.2): description, the full instructions
// (clamped, expandable, scrollable), every bundled file, and the guaranteed footer that
// answers "added WHERE, available WHEN". Shared verbatim with the parked Inbox card —
// one decision, one dialect.
export function SaveSkillPreview({ args }: { args: any }) {
return (
<>
{args?.description && <div className="approval-with">{String(args.description)}</div>}
{args?.instructions && <PreviewBlock text={String(args.instructions)} mono={false} />}
{Array.isArray(args?.files) && args.files.length > 0 && (
<div data-testid="skill-bundle-files">
{args.files.map((f: unknown, i: number) => (
<span className="approval-filechip" key={i}>
<span className="ico">
<Icon name="file" size={13} />
</span>
{String(f).split(/[\\/]/).pop() || String(f)}
</span>
))}
</div>
)}
<div className="approval-with">
Approving adds it to your skills on this computer usable in every conversation from
then on.
</div>
</>
);
}
// A `permissions` proposal on the create_scheduled_task consent card (§25): reads are
// disclosure lines, writes are the standing grants the approval mints.
interface PermissionLine {
@@ -65,14 +102,17 @@ export function scopeNote(
args: any,
category?: string,
): { text: string; external: boolean } {
// save_skill's corner answers WHERE (SKILLS-SPEC §5.2): the exact place to find, edit,
// or turn off the skill afterwards.
if (name === "save_skill") return { text: "saves to Settings ▸ Skills", external: false };
if (category === "connector") return { text: "acts on a connected service", external: true };
if (EXTERNAL.has(name)) {
const platform = String(args?.target ?? "").split(":")[0];
const names: Record<string, string> = { slack: "Slack", telegram: "Telegram" };
return { text: `leaves this Mac${names[platform] || platform || "a connected chat"}`, external: true };
return { text: `leaves this computer${names[platform] || platform || "a connected chat"}`, external: true };
}
const overwrite = name === "write_file" && args?.overwrite;
return { text: "stays on this Mac" + (overwrite ? " · overwrites the existing file" : ""), external: false };
return { text: "stays on this computer" + (overwrite ? " · overwrites the existing file" : ""), external: false };
}
// The proposed content/command, straight from the tool call's ARGS — the file/action
@@ -125,11 +165,13 @@ function Buttons({
onApprove,
runTask,
primaryLabel,
denyLabel = "Deny",
}: {
item: ApprovalItem;
onApprove: (decision: ApprovalDecision) => void;
runTask?: { id: string; title: string } | null;
primaryLabel: string;
denyLabel?: string;
}) {
const connector = item.category === "connector";
const offerStanding = !!(runTask && item.standingTarget);
@@ -152,7 +194,9 @@ function Buttons({
exactly the scope distinction §25 exists to draw. Same rule for run_shell:
the command-scoped button below is the specific (safer) grant, so the
tool-wide one stays out of the card. */}
{!connector && !offerStanding && item.name !== "run_shell" && (
{/* save_skill: no session-wide "always" every skill proposal gets its own review
(SKILLS-SPEC §5: one gate, always). */}
{!connector && !offerStanding && item.name !== "run_shell" && item.name !== "save_skill" && (
<button
className="btn"
title={`Always allow ${TOOL_VERBS[item.name]?.toLowerCase() || item.name} for this session`}
@@ -168,7 +212,7 @@ function Buttons({
)}
<span className="spacer" />
<button className="btn quiet-deny" onClick={() => onApprove("deny")}>
Deny
{denyLabel}
</button>
</div>
);
@@ -252,6 +296,8 @@ export function ApprovalCard({
{item.name === "send_message" && item.args?.text && (
<MessagePreview text={String(item.args.text)} />
)}
{/* save_skill (SKILLS-SPEC §5.2): the arguments ARE the review surface. */}
{item.name === "save_skill" && <SaveSkillPreview args={item.args} />}
{grants.length > 0 && (
<div className="approval-grants" data-testid="approval-grants">
@@ -272,7 +318,7 @@ export function ApprovalCard({
)}
{/* Long-tail tools: no bespoke preview — fall back to the compact args line. */}
{!FILE_WRITES.has(item.name) &&
!["run_shell", "send_message", "send_file"].includes(item.name) &&
!["run_shell", "send_message", "send_file", "save_skill"].includes(item.name) &&
!grants.length &&
shortArgs(item.args) && <div className="approval-rest">{shortArgs(item.args)}</div>}
{reason && <div className="approval-reason">{reason}</div>}
@@ -280,7 +326,13 @@ export function ApprovalCard({
{item.resolved ? (
<div className="resolved">Approved: {item.resolved.replace("_", " ")}</div>
) : (
<Buttons item={item} onApprove={onApprove} runTask={runTask} primaryLabel="Allow once" />
<Buttons
item={item}
onApprove={onApprove}
runTask={runTask}
primaryLabel={approvalActionLabels(item.name).allow}
denyLabel={approvalActionLabels(item.name).deny}
/>
)}
</div>
);
@@ -433,7 +433,7 @@ export function AutomationQuickstart({
<span className="block text-[13px] text-ink font-medium">
One sign-in unlocks every one-click connection
</span>
Connections are brokered by OpenWorker Cloud your tokens stay on this Mac.
Connections are brokered by OpenWorker Cloud your tokens stay on this computer.
<div className="flex items-center gap-3 mt-2">
{signinPhase ? (
<>
@@ -0,0 +1,150 @@
// SKILLS-SPEC §4.6 GUI — the composer's "/" force-run popup: opens only for a leading
// slash, lists only the session's effective (enabled) menu, filters while typing, and the
// picked skill rides onSend as its own field — never as message text.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Composer } from "./Composer";
const MENU = {
skills: [
{ name: "weekly-report", description: "Monday status report", scope: "global", enabled: true },
{ name: "greet", description: "says hello", scope: "project", enabled: true },
{ name: "muted-one", description: "muted here", scope: "global", enabled: false },
],
};
function stubFetch() {
const calls: { url: string; method: string }[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
calls.push({ url, method: (init?.method || "GET").toUpperCase() });
if (url.includes("/skills")) return { ok: true, json: async () => MENU } as Response;
return { ok: true, json: async () => ({}) } as Response;
}),
);
return calls;
}
const props = (extra: Partial<Parameters<typeof Composer>[0]> = {}) => ({
mode: "interactive",
model: "gpt-5.6-sol",
running: false,
connected: true,
sessionId: "s1",
onSend: vi.fn(),
onInterrupt: vi.fn(),
onModeChange: vi.fn(),
onModelChange: vi.fn(),
...extra,
});
const box = () => screen.getByPlaceholderText(/Ask the coworker/);
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("Composer / skills popup", () => {
it("opens on a leading '/' and lists only enabled skills from the effective menu", async () => {
stubFetch();
render(<Composer {...props()} />);
fireEvent.change(box(), { target: { value: "/" } });
await screen.findByTestId("skill-popup");
expect(await screen.findByText("/weekly-report")).toBeTruthy();
expect(screen.getByText("/greet")).toBeTruthy();
expect(screen.queryByText("/muted-one")).toBeNull(); // muted → not offered
expect(screen.getByText("project")).toBeTruthy(); // scope badge
});
it("filters as you type", async () => {
stubFetch();
render(<Composer {...props()} />);
fireEvent.change(box(), { target: { value: "/" } });
await screen.findByText("/weekly-report");
fireEvent.change(box(), { target: { value: "/wee" } });
expect(screen.getByText("/weekly-report")).toBeTruthy();
expect(screen.queryByText("/greet")).toBeNull();
});
it("does NOT open for a mid-text slash", async () => {
stubFetch();
render(<Composer {...props()} />);
fireEvent.change(box(), { target: { value: "rate 5/10 please" } });
expect(screen.queryByTestId("skill-popup")).toBeNull();
});
it("selecting inserts /name inline; the send strips the prefix and carries the skill field", async () => {
stubFetch();
const p = props();
render(<Composer {...p} />);
fireEvent.change(box(), { target: { value: "/gr" } });
fireEvent.click(await screen.findByRole("option", { name: /greet/ }));
expect((box() as HTMLTextAreaElement).value).toBe("/greet "); // inline, no chip
fireEvent.change(box(), { target: { value: "/greet say hi to the team" } });
fireEvent.keyDown(box(), { key: "Enter" });
await waitFor(() => expect(p.onSend).toHaveBeenCalled());
expect(p.onSend).toHaveBeenCalledWith("say hi to the team", [], "greet");
});
it("a skill-only send works and Enter inside the popup never sends the query text", async () => {
stubFetch();
const p = props();
render(<Composer {...p} />);
fireEvent.change(box(), { target: { value: "/wee" } });
await screen.findByText("/weekly-report");
fireEvent.keyDown(box(), { key: "Enter" }); // selects, does not send
expect(p.onSend).not.toHaveBeenCalled();
expect((box() as HTMLTextAreaElement).value).toBe("/weekly-report ");
fireEvent.keyDown(box(), { key: "Enter" }); // now sends, skill-only
await waitFor(() => expect(p.onSend).toHaveBeenCalledWith("", [], "weekly-report"));
});
it("editing the /name prefix away un-picks the skill", async () => {
stubFetch();
const p = props();
render(<Composer {...p} />);
fireEvent.change(box(), { target: { value: "/gr" } });
fireEvent.click(await screen.findByRole("option", { name: /greet/ }));
fireEvent.change(box(), { target: { value: "hello plain" } }); // prefix gone
fireEvent.keyDown(box(), { key: "Enter" });
await waitFor(() => expect(p.onSend).toHaveBeenCalledWith("hello plain", [], undefined));
});
it("Escape closes the popup and no popup ever opens without a sessionId", async () => {
stubFetch();
render(<Composer {...props()} />);
fireEvent.change(box(), { target: { value: "/gr" } });
await screen.findByTestId("skill-popup");
fireEvent.keyDown(box(), { key: "Escape" });
expect(screen.queryByTestId("skill-popup")).toBeNull();
cleanup();
stubFetch();
render(<Composer {...props({ sessionId: undefined })} />);
fireEvent.change(box(), { target: { value: "/" } });
expect(screen.queryByTestId("skill-popup")).toBeNull();
});
});
describe("Composer — the doorway prefill (SKILLS-SPEC §5.2)", () => {
it("a prefill arriving together with a session switch survives the draft clear", async () => {
stubFetch();
const { rerender } = render(<Composer {...props({ resetKey: "s1" })} />);
// The doorway does both in one render: new session (resetKey) + prefill. The clear
// effect must run BEFORE the prefill effect or the prefill is wiped (regression).
rerender(
<Composer
{...props({
resetKey: "s2",
prefill: { text: "Build a new skill for me: release procedure", nonce: 1 },
})}
/>,
);
await waitFor(() => {
expect((box() as HTMLTextAreaElement).value).toBe(
"Build a new skill for me: release procedure",
);
});
});
});
+128 -14
View File
@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import type { Attachment, SessionUsage } from "../types";
import { isPdfFile, readFile } from "../attach";
import { getSettings, inspectPdf } from "../api";
import { getSettings, inspectPdf, sessionSkills, type SessionSkillRow } from "../api";
import { formatTokens, totalTokens } from "../usage";
import { Dropdown, type Option } from "./Dropdown";
import { Icon } from "./Icon";
@@ -59,7 +59,10 @@ interface Props {
modelReady?: boolean;
onConnectModel?: () => void;
onConfigureVoiceInput?: () => void;
onSend: (text: string, attachments?: Attachment[]) => void;
onSend: (text: string, attachments?: Attachment[], skill?: string) => void;
// Feeds the "/" force-run popup (SKILLS-SPEC §4.1 #3): the popup lists this session's
// effective skill menu. Absent (e.g. tests without sessions) → the popup never opens.
sessionId?: string;
onInterrupt: () => void;
onModeChange: (mode: string) => void;
onModelChange: (model: string) => void;
@@ -91,6 +94,46 @@ interface Props {
export function Composer(props: Props) {
const [text, setText] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
// "/" force-run (SKILLS-SPEC §4.1 #3). The popup derives from the draft: it is open while
// the text is a bare "/query" (no whitespace yet) and no skill is picked. Selecting a row
// inserts "/name " INLINE in the box (Claude-Code style — the slash text IS the state);
// the user keeps typing after it, and on send the prefix is stripped while the skill name
// rides the user_message as its own field. Editing the prefix away un-picks the skill.
const [pendingSkill, setPendingSkill] = useState<SessionSkillRow | null>(null);
const [slashSkills, setSlashSkills] = useState<SessionSkillRow[] | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const prefixIntact =
pendingSkill !== null &&
(text === `/${pendingSkill.name}` || text.startsWith(`/${pendingSkill.name} `));
useEffect(() => {
if (pendingSkill && !prefixIntact) setPendingSkill(null);
}, [pendingSkill, prefixIntact]);
const slashQuery =
!prefixIntact && props.sessionId && text.startsWith("/") && !/\s/.test(text.slice(1))
? text.slice(1).toLowerCase()
: null;
const slashMatches = (slashSkills ?? []).filter((s) =>
s.name.toLowerCase().includes(slashQuery ?? ""),
);
useEffect(() => {
// Fetch on each popup open (fresh menu); drop when closed.
if (slashQuery === null) {
setSlashSkills(null);
setSlashIndex(0);
return;
}
if (slashSkills === null && props.sessionId) {
sessionSkills(props.sessionId, props.workspace)
.then((all) => setSlashSkills(all.filter((s) => s.enabled)))
.catch(() => setSlashSkills([]));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slashQuery === null]);
const pickSkill = (s: SessionSkillRow) => {
setPendingSkill(s);
setText(`/${s.name} `);
textareaRef.current?.focus();
};
const [dragging, setDragging] = useState(false);
const [attachMenuOpen, setAttachMenuOpen] = useState(false);
const [dictation, setDictation] = useState<DictationStatus | null>(null);
@@ -119,6 +162,17 @@ export function Composer(props: Props) {
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
}, [text]);
// Clear the draft when the conversation changes, so a half-typed message / picked file doesn't
// bleed from one session into another. Declared BEFORE the prefill effect: when both fire in
// the same render (the Skills doorway starts a new session AND prefills it), effects run in
// declaration order — clear first, then the prefill lands on the fresh session.
useEffect(() => {
setText("");
setAttachments([]);
setPendingSkill(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.resetKey]);
// Apply a prefill (text + attachments) pushed from outside, then focus the composer. Applied at
// most once per nonce (a ref guards against StrictMode/re-render double-fires), and attachments
// are de-duplicated so the same file never lands twice.
@@ -133,14 +187,6 @@ export function Composer(props: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.prefill?.nonce]);
// Clear the draft when the conversation changes, so a half-typed message / picked file doesn't
// bleed from one session into another.
useEffect(() => {
setText("");
setAttachments([]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.resetKey]);
// Dictation is intentionally native-only: the browser/dev build remains a local server client
// and never turns on the browser microphone or ships audio anywhere.
useEffect(() => {
@@ -264,19 +310,54 @@ export function Composer(props: Props) {
const needsModel = props.modelReady === false;
const submit = () => {
const t = text.trim();
if ((!t && attachments.length === 0) || props.running || dictation?.recording || dictationBusy) return;
// While the "/" popup is open the draft is a query, not a message — never send it.
if (slashQuery !== null) return;
// The visible "/name " prefix is UI state, not message text — strip it for the send;
// the skill rides as its own field.
const skill = prefixIntact ? pendingSkill!.name : undefined;
const t = (skill ? text.slice(skill.length + 1) : text).trim();
if (
(!t && attachments.length === 0 && !skill) ||
props.running ||
dictation?.recording ||
dictationBusy
)
return;
// No model connected: keep the draft (don't drop it) and send the user to setup instead.
if (needsModel) {
props.onConnectModel?.();
return;
}
props.onSend(t, attachments);
props.onSend(t, attachments, skill);
setText("");
setAttachments([]);
setPendingSkill(null);
};
const onKey = (e: React.KeyboardEvent) => {
if (slashQuery !== null) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSlashIndex((i) => Math.min(i + 1, Math.max(slashMatches.length - 1, 0)));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSlashIndex((i) => Math.max(i - 1, 0));
return;
}
if (e.key === "Escape") {
e.preventDefault();
setText("");
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const chosen = slashMatches[slashIndex];
if (chosen) pickSkill(chosen);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
@@ -342,7 +423,9 @@ export function Composer(props: Props) {
// The send button is accent only when there's something to send — subtle grey otherwise, so the
// composer isn't carrying a constant blue dot.
const hasContent = text.trim().length > 0 || attachments.length > 0;
// A pinned /skill is sendable content on its own (tester catch 2026-07-26: the arrow
// stayed grey after picking a skill, reading as "stuck").
const hasContent = text.trim().length > 0 || attachments.length > 0 || !!pendingSkill;
return (
<div className="composer-wrap px-6 pb-5 pt-4">
@@ -396,6 +479,37 @@ export function Composer(props: Props) {
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
}}
>
{/* "/" force-run popup in-flow above the textarea; rows are the session's
effective menu only (muted/disabled skills never appear). */}
{slashQuery !== null && (
<div className="px-2 pt-2" data-testid="skill-popup" role="listbox" aria-label="Skills">
{slashSkills === null ? (
<div className="px-2 py-1.5 text-[12px] text-faint">Loading skills</div>
) : slashMatches.length === 0 ? (
<div className="px-2 py-1.5 text-[12px] text-faint">No matching skills.</div>
) : (
slashMatches.map((s, i) => (
<button
key={s.name}
role="option"
aria-selected={i === slashIndex}
className={
"w-full text-left flex items-center gap-2 px-2 py-1.5 rounded-lg " +
(i === slashIndex ? "bg-paper" : "hover:bg-paper")
}
onMouseEnter={() => setSlashIndex(i)}
onClick={() => pickSkill(s)}
>
<span className="text-[13px] font-medium text-accent shrink-0">/{s.name}</span>
<span className="text-[12px] text-faint truncate flex-1">{s.description}</span>
<span className="text-[10.5px] px-1.5 py-0.5 rounded-full border border-line text-faint shrink-0">
{s.scope}
</span>
</button>
))
)}
</div>
)}
<textarea
ref={textareaRef}
className="w-full block px-3.5 pt-3.5 pb-1.5 text-[14.5px]"
+13
View File
@@ -9,6 +9,7 @@ export type IconName =
| "signOut"
| "chat"
| "diamond"
| "book"
| "search"
| "folder"
| "folderPlus"
@@ -66,6 +67,18 @@ export function Icon({
};
switch (name) {
case "book":
// A playbook — Skills are the worker's recipe book (Settings ▸ Skills).
// Hardcover with a full spine + two text lines: "written instructions inside".
// (Owner-picked from a 15px-preview comparison, 2026-07-27.)
return (
<svg {...s}>
<path d="M6 2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z" />
<path d="M8.5 2v20" />
<path d="M11.5 8.5h5" />
<path d="M11.5 12h3.5" />
</svg>
);
case "sparkle":
// Filled 4-point twinkle — crisp at small sizes.
return (
+13 -4
View File
@@ -1,7 +1,13 @@
import { useState, type ReactNode } from "react";
import type { InboxItem } from "../api";
import { humanizeApprovalTitle } from "../humanize";
import { PreviewBlock, scopeNote, TitleText } from "./ApprovalCard";
import {
approvalActionLabels,
PreviewBlock,
SaveSkillPreview,
scopeNote,
TitleText,
} from "./ApprovalCard";
// One Inbox item, rendered identically in the Inbox list and inline in its own session view
// (answer-in-context). Resolving either place hits the same item id — first responder wins.
@@ -88,7 +94,10 @@ export function InboxItemCard({
<div className="text-[15px] font-semibold mt-0.5 leading-snug">{item.title}</div>
</>
)}
{item.kind === "approval" && item.data?.tool && typeof item.data.arguments?.content === "string" ? (
{item.kind === "approval" && item.data?.tool === "save_skill" ? (
// Parked skill proposals wear the same review surface as the live card (§5.2).
<SaveSkillPreview args={item.data.arguments} />
) : item.kind === "approval" && item.data?.tool && typeof item.data.arguments?.content === "string" ? (
<PreviewBlock text={item.data.arguments.content} />
) : item.kind === "approval" && item.data?.tool && typeof item.data.arguments?.command === "string" ? (
<PreviewBlock text={item.data.arguments.command} />
@@ -102,7 +111,7 @@ export function InboxItemCard({
className={item.data?.tool ? BTN_ACCENT : BTN_PRIMARY}
onClick={() => onResolve(item.id, "allow")}
>
{item.data?.tool ? "Allow once" : "Approve"}
{item.data?.tool ? approvalActionLabels(item.data.tool).allow : "Approve"}
</button>
{/* Task-persistent standing grant (§25) present only when the approval was
raised inside an automation run AND the call can carry a tool+target rule.
@@ -120,7 +129,7 @@ export function InboxItemCard({
className={item.data?.tool ? BTN_QUIET : BTN_BORDERED}
onClick={() => onResolve(item.id, "deny")}
>
Deny
{item.data?.tool ? approvalActionLabels(item.data.tool).deny : "Deny"}
</button>
</div>
) : item.kind === "question" ? (
+3 -3
View File
@@ -122,7 +122,7 @@ export function Onboarding({ onDone }: { onDone: (next?: "work" | "gallery" | "a
<h1 className="text-[19px] font-semibold">Welcome to OpenWorker<span className="beta-tag">BETA</span></h1>
<p className="text-[13px] text-muted mt-0.5 mb-4">
Pick a model provider to get started OpenWorker runs on your own key, and your
key and your data stay on this Mac.
key and your data stay on this computer.
</p>
{!ps.sel ? (
@@ -239,7 +239,7 @@ export function Onboarding({ onDone }: { onDone: (next?: "work" | "gallery" | "a
Sign in for one-click connections
</span>
OpenWorker handles the OAuth for 20+ tools no dev consoles, no pasted keys.
Tokens stay on this Mac.
Tokens stay on this computer.
</span>
{signinPhase ? (
<span className="inline-flex items-center gap-2 text-[12.5px] text-muted shrink-0">
@@ -310,7 +310,7 @@ export function Onboarding({ onDone }: { onDone: (next?: "work" | "gallery" | "a
</div>
<p className="text-[11px] text-faint mt-3">
30+ more tools on the Connectors page add or remove anytime. Tokens stay on
this Mac.
this computer.
</p>
</section>
)}
+28
View File
@@ -159,6 +159,15 @@ export function RightRail({
content={content}
onReload={reloadSelected}
onBack={() => setSelected(null)}
onOpenEntry={(path) =>
setSelected({
path,
name: path.split("/").pop() || path,
kind: kindFromPath(path),
size: 0,
modified_at: 0,
})
}
/>
) : (
<>
@@ -291,12 +300,15 @@ function ArtifactViewer({
content,
onReload,
onBack,
onOpenEntry,
}: {
sessionId: string;
artifact: ArtifactInfo;
content: ArtifactContent | null;
onReload: () => Promise<void>;
onBack: () => void;
// Folder listings: open a child entry in the viewer (files and subfolders alike).
onOpenEntry?: (path: string) => void;
}) {
const [reloadKey, setReloadKey] = useState(0);
const isHtml = content?.kind === "html" && !content.error;
@@ -381,6 +393,22 @@ function ArtifactViewer({
<CsvTable text={content.content || ""} />
) : content.kind === "sheet" ? (
<SheetViewer dataUrl={content.data_url || ""} />
) : content.kind === "folder" ? (
// A linked directory (e.g. a skill package): render the listing, click through.
<div className="artifact-folderlist" data-testid="artifact-folder">
{(content.entries || []).map((e) => (
<button
key={e.name}
className="artifact-folder-row"
onClick={() => onOpenEntry?.(`${artifact.path.replace(/\/+$/, "")}/${e.name}`)}
>
<Icon name={e.dir ? "folder" : "file"} size={14} />
<span className="artifact-folder-name">{e.name}</span>
{!e.dir && <span className="artifact-folder-size">{formatBytes(e.size)}</span>}
</button>
))}
{!content.entries?.length && <div className="rail-muted">This folder is empty.</div>}
</div>
) : content.kind === "office" ? (
<div className="artifact-open-prompt">
<Icon name="panelOpen" size={28} />
+10 -2
View File
@@ -41,6 +41,7 @@ import { PanelHead } from "./IntegrationsView";
import { ModelsTab } from "./ManageTabs";
import { GalleryModal } from "./GalleryModal";
import { PersonasTab } from "./PersonasTab";
import { SkillsTab } from "./SkillsTab";
import { showPersonas } from "../flags";
// Settings, restructured (Option 2) into a full-page surface that mirrors IntegrationsView's shell:
@@ -50,7 +51,7 @@ import { showPersonas } from "../flags";
// Models + Personas host the existing tab components inside the page shell (field re-skin to follow).
// "appearance" is the General tab's stable key — callers deep-link with it, so the
// rename (UX-021) changed only the label. "files" folded into General as a card.
type SetTab = "appearance" | "models" | "voice" | "personas";
type SetTab = "appearance" | "models" | "skills" | "voice" | "personas";
const CARD = "rounded-xl2 border border-line bg-panel";
const FIELD_LABEL = "text-[12.5px] font-medium text-ink";
@@ -61,9 +62,10 @@ const BTN_ACCENT = "text-[12.5px] px-3 py-2 rounded-lg bg-accent text-white shri
const BTN_BORDERED =
"text-[12.5px] px-3 py-2 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" | "sparkle" }[] = [
const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" | "sparkle" | "book" }[] = [
{ key: "appearance", label: "General", icon: "sliders" },
{ key: "models", label: "Models", icon: "code" },
{ key: "skills", label: "Skills", icon: "book" },
{ key: "voice", label: "Voice input", icon: "mic" },
{ key: "personas", label: "Personas", icon: "sparkle" },
];
@@ -71,9 +73,13 @@ const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" |
export function SettingsView({
initialTab,
onOpenPersona,
onCreateSkill,
}: {
initialTab?: SetTab;
onOpenPersona?: (id: string) => void;
// Skills doorway (SKILLS-SPEC §5.2): start a new conversation with the description
// prefilled — the worker builds the skill and proposes it via save_skill.
onCreateSkill?: (description: string) => void;
}) {
// Personas is flag-gated (hidden for launch) — filter the tab AND coerce a stale
// deep-link to it (openSettings("personas") callers) so the page never opens on a
@@ -124,6 +130,8 @@ export function SettingsView({
<CompactionCard />
</div>
</section>
) : tab === "skills" ? (
<SkillsTab onCreateSkill={onCreateSkill} />
) : tab === "voice" ? (
<VoiceInputSection />
) : (
@@ -0,0 +1,286 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { SkillsTab } from "./SkillsTab";
// SKILLS-SPEC §5/§6 GUI — Settings ▸ Skills: list + badges + rich-skill file counts, form
// validation, the doors (write form / upload-with-preview / doorway-to-conversation).
type Call = { url: string; method: string; body: any };
function stubFetch(routes: { match: string; method?: string; json: any }[]) {
const calls: Call[] = [];
const fn = vi.fn(async (url: string, init?: RequestInit) => {
const method = (init?.method || "GET").toUpperCase();
calls.push({ url, method, body: init?.body ? JSON.parse(String(init.body)) : undefined });
for (const r of routes) {
if (url.includes(r.match) && (!r.method || r.method === method)) {
return { ok: true, json: async () => r.json } as Response;
}
}
return { ok: true, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fn);
return calls;
}
const ROW = {
name: "weekly-report",
description: "Monday status report",
instructions: "1. Collect updates\n2. Write it up",
scope: "global",
source: "local",
enabled: true,
path: "/skills/weekly-report",
};
const UPLOADED_ROW = {
...ROW,
name: "greet",
description: "says hello",
source: "uploaded",
enabled: false,
};
const LIST = { skills: [ROW, UPLOADED_ROW] };
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// The single add-action: open the "Add skill" menu, pick a door (SKILLS-SPEC §5).
const openWriteForm = async () => {
fireEvent.click(await screen.findByRole("button", { name: /Add skill/ }));
fireEvent.click(screen.getByText("Write it myself"));
};
describe("SkillsTab", () => {
it("renders rows with provenance badges and dims disabled skills", async () => {
stubFetch([{ match: "/v1/skills", method: "GET", json: LIST }]);
render(<SkillsTab />);
expect(await screen.findByText("weekly-report")).toBeTruthy();
expect(screen.getByText("Monday status report")).toBeTruthy();
expect(screen.queryByText("global")).toBeNull(); // no scope badges — global-only (§4.7)
expect(screen.getByText("uploaded")).toBeTruthy(); // provenance badge stays
const toggles = screen.getAllByRole("switch");
expect((toggles[0] as HTMLInputElement).checked).toBe(true);
expect((toggles[1] as HTMLInputElement).checked).toBe(false);
});
it("blocks Save until name and instructions are filled", async () => {
stubFetch([{ match: "/v1/skills", method: "GET", json: { skills: [] } }]);
render(<SkillsTab />);
await openWriteForm();
const save = screen.getByText("Save skill") as HTMLButtonElement;
expect(save.disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "greet" } });
expect(save.disabled).toBe(true); // instructions still empty
fireEvent.change(screen.getByLabelText("Instructions"), {
target: { value: "Say hello." },
});
expect(save.disabled).toBe(false);
});
it("creates a skill (global, no scope field) and refreshes the list", async () => {
const calls = stubFetch([
{ match: "/v1/skills", method: "GET", json: { skills: [] } },
{ match: "/v1/skills", method: "POST", json: { ok: true } },
]);
render(<SkillsTab />);
await openWriteForm();
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "greet" } });
fireEvent.change(screen.getByLabelText("Instructions"), {
target: { value: "Say hello." },
});
fireEvent.click(screen.getByText("Save skill"));
await waitFor(() => {
const post = calls.find((c) => c.method === "POST" && c.url.endsWith("/v1/skills"));
expect(post?.body).toMatchObject({ name: "greet", instructions: "Say hello." });
expect(post?.body.workspace).toBeUndefined(); // global-only: no scope/workspace sent
});
// list re-fetched after save
expect(calls.filter((c) => c.method === "GET" && c.url.includes("/v1/skills")).length).toBeGreaterThan(1);
});
it("edit prefills the form (name locked, body loaded) and PATCHes on save", async () => {
const calls = stubFetch([
{ match: "/v1/skills", method: "GET", json: LIST },
{ match: "/v1/skills/weekly-report", method: "PATCH", json: { ok: true } },
]);
render(<SkillsTab />);
await screen.findByText("weekly-report");
fireEvent.click(screen.getAllByTitle("Edit")[0]);
const name = screen.getByLabelText("Name") as HTMLInputElement;
expect(name.value).toBe("weekly-report");
expect(name.disabled).toBe(true);
const body = screen.getByLabelText("Instructions") as HTMLTextAreaElement;
expect(body.value).toContain("Collect updates");
fireEvent.change(body, { target: { value: "New steps" } });
fireEvent.click(screen.getByText("Save skill"));
await waitFor(() => {
const patch = calls.find((c) => c.method === "PATCH");
expect(patch?.url).toContain("/v1/skills/weekly-report");
expect(patch?.body.instructions).toBe("New steps");
});
});
it("delete is two-step: arm, then DELETE on confirm", async () => {
const calls = stubFetch([
{ match: "/v1/skills", method: "GET", json: LIST },
{ match: "/v1/skills/weekly-report", method: "DELETE", json: { ok: true } },
]);
render(<SkillsTab />);
await screen.findByText("weekly-report");
// arm via the trash button (renders "Confirm delete" once armed)
fireEvent.click(screen.getByLabelText("Delete weekly-report"));
expect(calls.some((c) => c.method === "DELETE")).toBe(false);
const confirm = await screen.findByText("Confirm delete");
fireEvent.click(confirm);
await waitFor(() => {
expect(calls.some((c) => c.method === "DELETE" && c.url.includes("weekly-report"))).toBe(true);
});
});
it("the enabled switch PATCHes {enabled} and teaches the off rule + physics footnote", async () => {
const calls = stubFetch([
{ match: "/v1/skills", method: "GET", json: LIST },
{ match: "/v1/skills/weekly-report", method: "PATCH", json: { ok: true } },
]);
render(<SkillsTab />);
await screen.findByText("weekly-report");
fireEvent.click(screen.getByLabelText("weekly-report enabled"));
await waitFor(() => {
const patch = calls.find((c) => c.method === "PATCH");
expect(patch?.body).toMatchObject({ enabled: false });
});
const status = await screen.findByRole("status");
expect(status.textContent).toContain("weekly-report"); // name-first — WHICH skill
expect(status.textContent).toContain("turned off everywhere");
expect(status.textContent).toContain("clean slate"); // the guaranteed remedy, in place
});
it("upload shows the parsed preview and installs nothing until confirmed", async () => {
const calls = stubFetch([
{ match: "/v1/skills/upload/confirm", method: "POST", json: { ok: true } },
{
match: "/v1/skills/upload",
method: "POST",
json: {
ok: true,
token: "t1",
name: "greet",
description: "says hello",
instructions: "Say hello warmly.",
files: ["notes.txt"],
},
},
{ match: "/v1/skills", method: "GET", json: { skills: [] } },
]);
render(<SkillsTab />);
const input = (await screen.findByLabelText("Upload a skill archive")) as HTMLInputElement;
const file = new File([new Uint8Array([80, 75, 3, 4])], "greet.zip", { type: "application/zip" });
fireEvent.change(input, { target: { files: [file] } });
await screen.findByText("Review before installing");
expect(screen.getByText("Say hello warmly.")).toBeTruthy();
expect(screen.getByText(/notes\.txt/)).toBeTruthy();
expect(calls.some((c) => c.url.includes("/upload/confirm"))).toBe(false); // preview ≠ install
fireEvent.click(screen.getByText("Install skill"));
await waitFor(() => {
const confirm = calls.find((c) => c.url.includes("/upload/confirm"));
expect(confirm?.body).toMatchObject({ token: "t1" });
});
});
it("Add skill menu: three doors; Create with OpenWorker hands off to a conversation", async () => {
const calls = stubFetch([{ match: "/v1/skills", method: "GET", json: { skills: [] } }]);
const onCreateSkill = vi.fn();
render(<SkillsTab onCreateSkill={onCreateSkill} />);
fireEvent.click(await screen.findByRole("button", { name: /Add skill/ }));
// The three doors (§5), each with its teaching subtitle.
expect(screen.getByText("Write it myself")).toBeTruthy();
expect(screen.getByText("Import a file")).toBeTruthy();
expect(screen.getByText(/you review before it installs/)).toBeTruthy();
expect(screen.getByText(/asks before adding it to\s+your skills/)).toBeTruthy();
fireEvent.click(screen.getByText("Create with OpenWorker"));
// Straight to the conversation — the composer is where you describe it (§5.2).
expect(onCreateSkill).toHaveBeenCalledWith("");
// Settings never drafts: no POST of any kind happened.
expect(calls.some((c) => c.method === "POST")).toBe(false);
});
it("offers no scope UI at all — skills are global (§4.7)", async () => {
stubFetch([{ match: "/v1/skills", method: "GET", json: { skills: [] } }]);
render(<SkillsTab />);
await openWriteForm();
expect(screen.queryByText("Available in")).toBeNull();
expect(screen.queryByLabelText("Everywhere")).toBeNull();
expect(screen.queryByLabelText("Only one project")).toBeNull();
expect(screen.queryByText(/Move to/)).toBeNull();
});
it("shows the new-session confirmation line after creating a skill", async () => {
stubFetch([
{ match: "/v1/skills", method: "GET", json: { skills: [] } },
{ match: "/v1/skills", method: "POST", json: { ok: true } },
]);
render(<SkillsTab />);
await openWriteForm();
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "greet" } });
fireEvent.change(screen.getByLabelText("Instructions"), { target: { value: "x" } });
fireEvent.click(screen.getByText("Save skill"));
const status = await screen.findByRole("status");
expect(status.textContent).toContain("greet"); // name-first — WHICH skill
expect(status.textContent).toContain("can now use it in every conversation");
});
it("the list is the page: no standing add-surfaces, no drafting remnants", async () => {
stubFetch([{ match: "/v1/skills", method: "GET", json: { skills: [] } }]);
render(<SkillsTab onCreateSkill={vi.fn()} />);
await screen.findByRole("button", { name: /Add skill/ });
// No permanently-open description box or draft-era UI (§5.2/§9) — adding is menu-only.
expect(screen.queryByLabelText("Describe the skill")).toBeNull();
expect(screen.queryByText("Start a conversation")).toBeNull();
expect(screen.queryByText("Ask OpenWorker to revise")).toBeNull();
expect(screen.queryByText(/Not a chat/)).toBeNull();
// The menu closes after picking a door.
await openWriteForm();
expect(screen.queryByText("Write it myself")).toBeNull();
expect(screen.getByText("Save skill")).toBeTruthy();
});
it("surfaces server-side validation errors", async () => {
stubFetch([
{ match: "/v1/skills", method: "GET", json: { skills: [] } },
{ match: "/v1/skills", method: "POST", json: { ok: false, error: "A skill named 'x' already exists in that scope." } },
]);
render(<SkillsTab />);
await openWriteForm();
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } });
fireEvent.change(screen.getByLabelText("Instructions"), { target: { value: "y" } });
fireEvent.click(screen.getByText("Save skill"));
expect(await screen.findByRole("alert")).toBeTruthy();
expect(screen.getByText(/already exists/)).toBeTruthy();
});
});
describe("SkillsTab — rich-skill disclosure (§6)", () => {
it("shows a file count only when a skill bundles resources", async () => {
stubFetch([
{
match: "/v1/skills",
method: "GET",
json: {
skills: [
{ name: "plain", description: "d", instructions: "i", scope: "global", source: "local", enabled: true, path: "/p", files: 0 },
{ name: "rich", description: "d", instructions: "i", scope: "global", source: "uploaded", enabled: true, path: "/r", files: 3 },
],
},
},
]);
render(<SkillsTab />);
const note = await screen.findByTitle("Show folder");
expect(note.textContent).toContain("3 files");
// The one-file skill carries no count at all — only rich skills are marked.
expect(screen.getAllByTitle("Show folder")).toHaveLength(1);
});
});
+440
View File
@@ -0,0 +1,440 @@
import { useRef, useState } from "react";
import { useEffect } from "react";
import {
createSkill,
deleteSkill,
listSkills,
revealSkill,
stageSkillUpload,
confirmSkillUpload,
updateSkill,
type SkillRow,
type SkillUploadPreview,
} from "../api";
import { Icon } from "./Icon";
// Settings ▸ Skills (SKILLS-SPEC §5/§6) — the management home: the LIST is the page; every
// add-surface appears only when summoned from the single "Add skill" menu (the three doors:
// write form / import / start-a-conversation). Everything a user creates here is GLOBAL —
// "skills are things your worker knows everywhere". Creation-by-AI is a CONVERSATION (the
// menu's third door starts one; the worker proposes via save_skill) — there is no
// in-Settings drafting and no description box: the composer is where you describe it.
// Persona-bundled skills arrive with personas (§10), managed on the persona page, not here.
const CARD = "rounded-xl2 border border-line bg-panel";
const FIELD_LABEL = "text-[12.5px] font-medium text-ink";
const INPUT =
"w-full min-w-0 px-3 py-2 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent";
const BTN_ACCENT =
"text-[12.5px] px-3 py-2 rounded-lg bg-accent text-white shrink-0 disabled:opacity-40";
const BTN_BORDERED =
"text-[12.5px] px-3 py-2 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
const BADGE =
"text-[11px] px-2 py-0.5 rounded-full border border-line bg-paper text-muted shrink-0";
type Editor = {
mode: "new" | "edit";
name: string;
description: string;
instructions: string;
};
const emptyEditor = (): Editor => ({
mode: "new",
name: "",
description: "",
instructions: "",
});
async function fileToB64(file: File): Promise<string> {
// FileReader fallback: File.arrayBuffer is missing in some webviews (and jsdom).
const buf =
typeof file.arrayBuffer === "function"
? await file.arrayBuffer()
: await new Promise<ArrayBuffer>((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result as ArrayBuffer);
r.onerror = () => reject(r.error);
r.readAsArrayBuffer(file);
});
const bytes = new Uint8Array(buf);
let bin = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(bin);
}
export function SkillsTab({
onCreateSkill,
}: {
// The doorway (SKILLS-SPEC §5.2): starts a new conversation with the description
// prefilled in the composer — the worker builds the skill and proposes it via save_skill.
onCreateSkill?: (description: string) => void;
}) {
const [rows, setRows] = useState<SkillRow[]>([]);
const [editor, setEditor] = useState<Editor | null>(null);
const [upload, setUpload] = useState<SkillUploadPreview | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [armedDelete, setArmedDelete] = useState<string | null>(null);
const [error, setError] = useState("");
// The state-change callout (SKILLS-SPEC §4.1 #2): name-first so the user knows WHICH
// skill, and visually distinct so it can't be skimmed past (tester ask 2026-07-27).
const [notice, setNotice] = useState<{ name: string; text: string; tone: "ok" | "warn" } | null>(
null,
);
const fileInput = useRef<HTMLInputElement>(null);
// Confirmation copy (SKILLS-SPEC §4.1 #2): name-first, outcome + remedy only, in words a
// person already owns — now / everywhere / off / start a new one. Never mechanism ("the
// model will be told…") or engineering timing ("from the next message") — owner-driver
// review rounds, 2026-07-27. The engine countermands disabled-but-loaded skills silently;
// the copy promises only the guaranteed part.
const CONFIRMATION = "— the worker can now use it in every conversation.";
const OFF_NOTE =
"turned off everywhere. If a conversation already used it, start a new one for a completely clean slate.";
const DELETE_NOTE =
"removed. If a conversation already used it, start a new one for a completely clean slate.";
const refresh = () => listSkills().then(setRows);
useEffect(() => {
refresh();
}, []);
const fail = (res: { ok?: boolean; error?: string }) => {
setNotice(null);
if (res.ok === false) {
setError(res.error || "Something went wrong.");
return true;
}
setError("");
return false;
};
const save = async () => {
if (!editor) return;
const res =
editor.mode === "new"
? await createSkill({
name: editor.name.trim(),
description: editor.description.trim(),
instructions: editor.instructions,
})
: await updateSkill(editor.name, {
description: editor.description.trim(),
instructions: editor.instructions,
});
if (fail(res)) return;
setEditor(null);
if (editor.mode === "new")
setNotice({ name: editor.name.trim(), text: CONFIRMATION, tone: "ok" });
refresh();
};
const onPickFile = async (file: File | undefined) => {
if (!file) return;
const res = await stageSkillUpload(await fileToB64(file), file.name);
if (fail(res)) return;
setUpload(res);
};
const confirmUpload = async () => {
if (!upload?.token) return;
const res = await confirmSkillUpload(upload.token);
if (fail(res)) return;
setUpload(null);
setNotice({ name: upload.name || "Skill", text: CONFIRMATION, tone: "ok" });
refresh();
};
const remove = async (row: SkillRow) => {
if (armedDelete !== row.name) {
setArmedDelete(row.name);
return;
}
setArmedDelete(null);
const res = await deleteSkill(row.name);
if (fail(res)) return;
setNotice({ name: row.name, text: DELETE_NOTE, tone: "warn" });
refresh();
};
return (
<section>
<div className="flex items-start justify-between gap-3 mb-4">
<div>
<h2 className="text-[16px] font-semibold">Skills</h2>
<p className="text-[12.5px] text-muted mt-1 leading-relaxed">
Reusable instructions the worker can follow in every conversation. Off here means
off everywhere.
</p>
</div>
{/* One add-action, three doors behind it (SKILLS-SPEC §5): the list is the page. */}
<div className="relative shrink-0">
<button
className={BTN_ACCENT}
aria-haspopup="menu"
aria-expanded={addOpen}
onClick={() => setAddOpen((v) => !v)}
>
<span className="inline-flex items-center gap-1.5">
<Icon name="plus" size={13} /> Add skill
</span>
</button>
{addOpen ? (
<>
<div className="fixed inset-0 z-10" onClick={() => setAddOpen(false)} />
<div
role="menu"
className="absolute right-0 top-full mt-1.5 w-80 rounded-xl2 border border-line bg-panel shadow-xl z-20 p-1.5"
onKeyDown={(e) => e.key === "Escape" && setAddOpen(false)}
>
<button
role="menuitem"
className="w-full text-left px-3 py-2 rounded-lg hover:bg-paper"
onClick={() => {
setAddOpen(false);
setEditor(emptyEditor());
}}
>
<div className="text-[13px] font-medium">Write it myself</div>
<div className="text-[11.5px] text-muted">
A name, a description, and the instructions
</div>
</button>
<button
role="menuitem"
className="w-full text-left px-3 py-2 rounded-lg hover:bg-paper"
onClick={() => {
setAddOpen(false);
fileInput.current?.click();
}}
>
<div className="text-[13px] font-medium">Import a file</div>
<div className="text-[11.5px] text-muted">
A .zip or SKILL.md someone shared you review before it installs
</div>
</button>
<button
role="menuitem"
className="w-full text-left px-3 py-2 rounded-lg hover:bg-paper disabled:opacity-40"
disabled={!onCreateSkill}
onClick={() => {
setAddOpen(false);
onCreateSkill?.("");
}}
>
<div className="text-[13px] font-medium">Create with OpenWorker</div>
<div className="text-[11.5px] text-muted">
Starts a conversation the worker builds it and asks before adding it to
your skills
</div>
</button>
</div>
</>
) : null}
</div>
</div>
<input
ref={fileInput}
type="file"
accept=".zip,.md"
className="hidden"
aria-label="Upload a skill archive"
onChange={(e) => {
onPickFile(e.target.files?.[0]);
e.target.value = "";
}}
/>
{error ? (
<div className="text-[12.5px] text-red-500 mb-3" role="alert">
{error}
</div>
) : null}
{notice ? (
<div
role="status"
className={
"mb-3 flex items-start gap-2 rounded-lg border px-3 py-2 text-[12.5px] " +
(notice.tone === "ok"
? "bg-tealSoft/70 text-tealInk border-tealInk/20"
: "bg-warnSoft/70 text-warnInk border-warnInk/20")
}
>
<span className="min-w-0">
<b>{notice.name}</b> {notice.text}
</span>
<button
className="ml-auto shrink-0 opacity-60 hover:opacity-100"
aria-label="Dismiss"
onClick={() => setNotice(null)}
>
</button>
</div>
) : null}
{upload ? (
<div className={`${CARD} p-4 mb-4`}>
<div className="text-[13px] font-medium mb-1">Review before installing</div>
<p className="text-[12.5px] text-muted mb-3">
Read the instructions installing a skill means the worker will follow them.
</p>
<div className="text-[13px] mb-1">
<span className="font-medium">{upload.name}</span>
<span className="text-muted"> {upload.description || "no description"}</span>
</div>
<pre className="text-[12px] bg-paper border border-line rounded-lg p-3 whitespace-pre-wrap max-h-64 overflow-y-auto mb-2">
{upload.instructions}
</pre>
{upload.files?.length ? (
<div className="text-[12px] text-muted mb-2">
Bundled files: {upload.files.join(", ")}
</div>
) : null}
<div className="flex gap-2 mt-3">
<button className={BTN_ACCENT} onClick={confirmUpload}>
Install skill
</button>
<button className={BTN_BORDERED} onClick={() => setUpload(null)}>
Cancel
</button>
</div>
</div>
) : null}
{editor ? (
<div className={`${CARD} p-4 mb-4`}>
<div className="text-[13px] font-medium mb-3">
{editor.mode === "new" ? "New skill" : `Edit ${editor.name}`}
</div>
<label className={FIELD_LABEL} htmlFor="skill-name">
Name
</label>
<input
id="skill-name"
className={`${INPUT} mt-1 mb-3`}
value={editor.name}
disabled={editor.mode === "edit"}
placeholder="weekly-report"
onChange={(e) => setEditor({ ...editor, name: e.target.value })}
/>
<label className={FIELD_LABEL} htmlFor="skill-desc">
Description
</label>
<input
id="skill-desc"
className={`${INPUT} mt-1 mb-3`}
value={editor.description}
placeholder="One line the worker uses to decide when this applies"
onChange={(e) => setEditor({ ...editor, description: e.target.value })}
/>
<label className={FIELD_LABEL} htmlFor="skill-instructions">
Instructions
</label>
<textarea
id="skill-instructions"
className={`${INPUT} mt-1 mb-3 min-h-[140px] font-mono`}
value={editor.instructions}
placeholder={"1. Gather last week's updates\n2. Write the report, under 300 words"}
onChange={(e) => setEditor({ ...editor, instructions: e.target.value })}
/>
<div className="flex gap-2 mt-3">
<button
className={BTN_ACCENT}
disabled={!editor.name.trim() || !editor.instructions.trim()}
onClick={save}
>
Save skill
</button>
<button className={BTN_BORDERED} onClick={() => setEditor(null)}>
Cancel
</button>
</div>
</div>
) : null}
<div className={`${CARD} divide-y divide-line`}>
{rows.length === 0 && !editor ? (
<div className="p-5 text-[13px] text-muted">
No skills yet <b>Add skill</b> teaches your worker its first one, like
prepare my Monday status report.
</div>
) : null}
{rows.map((row) => (
<div key={row.name} className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-[13px] font-medium ${row.enabled ? "" : "text-muted"}`}>
{row.name}
</span>
{row.source !== "local" ? <span className={BADGE}>{row.source}</span> : null}
{/* §6: a rich skill must not look identical to a one-file one. Styled as a
chip with a folder icon so it READS as clickable (live drive: plain
text hid the affordance). */}
{row.files ? (
<button
className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded-md border border-line bg-paper text-muted hover:text-ink hover:border-lineStrong shrink-0"
title="Show folder"
onClick={() => revealSkill(row.name)}
>
<Icon name="folder" size={11} /> {row.files} file{row.files === 1 ? "" : "s"}
</button>
) : null}
</div>
{/* Full description, wrapping a skill's one-liner is its menu entry; cutting
it mid-word hid what the skill does (live drive). */}
<div className="text-[12px] text-muted leading-relaxed">{row.description}</div>
</div>
<button
className={BTN_BORDERED}
title="Edit"
onClick={() =>
setEditor({
mode: "edit",
name: row.name,
description: row.description,
instructions: row.instructions,
})
}
>
<Icon name="pencil" size={13} />
</button>
<button
className={BTN_BORDERED}
aria-label={`Delete ${row.name}`}
onClick={() => remove(row)}
onBlur={() => setArmedDelete(null)}
>
{armedDelete === row.name ? "Confirm delete" : <Icon name="trash" size={13} />}
</button>
<label className="inline-flex items-center gap-1.5 text-[12px] text-muted">
<input
type="checkbox"
role="switch"
aria-label={`${row.name} enabled`}
checked={row.enabled}
onChange={(e) => {
const on = e.target.checked;
updateSkill(row.name, { enabled: on }).then((res) => {
if (!fail(res))
setNotice({
name: row.name,
text: on ? CONFIRMATION : OFF_NOTE,
tone: on ? "ok" : "warn",
});
refresh();
});
}}
/>
On
</label>
</div>
))}
</div>
</section>
);
}
+9 -1
View File
@@ -187,7 +187,15 @@ function StepRow({ tool, approval }: { tool: ToolItem; approval?: ApprovalItem }
<span className={"w-3.5 text-center text-[10px] shrink-0 " + (failed ? "text-danger" : running ? "text-accent" : "text-ok")}>
{running ? <span className="spinner" data-testid="step-running" /> : "●"}
</span>
<LineText line={humanizeTool(tool.name, tool.args)} />
<LineText
line={
// A refused load must not read as a success — "Used skill:" is the trust line
// (SKILLS-SPEC §4.1 #4), so a blocked attempt gets honest wording instead.
tool.name === "load_skill" && tool.preview?.includes('"error"')
? { pre: "Tried skill: ", obj: String(tool.args?.name ?? ""), post: " — not available" }
: humanizeTool(tool.name, tool.args)
}
/>
{approval && approvalChip(approval.resolved)}
{!!tool.standingRule && (
<span
+17
View File
@@ -0,0 +1,17 @@
// SKILLS-SPEC §4.6 GUI — the transcript trust line: a load_skill tool call always renders
// as a human-readable "Used skill: X" step, whether model-invoked or forced via /skill.
import { describe, expect, it } from "vitest";
import { humanizeTool } from "./humanize";
describe("humanizeTool(load_skill)", () => {
it("renders the Used-skill line with the skill name", () => {
const line = humanizeTool("load_skill", { name: "incident-summary" });
expect(line.pre).toBe("Used skill: ");
expect(line.obj).toBe("incident-summary");
});
it("stays safe on null/missing args", () => {
expect(humanizeTool("load_skill", null).obj).toBe("");
expect(humanizeTool("load_skill", {}).obj).toBe("");
});
});
+9
View File
@@ -88,6 +88,10 @@ export function humanizeTool(name: string, args: any): HumanLine {
}
case "explore":
return { pre: "Sent a sub-agent to explore — ", obj: `${trunc(String(a.task ?? a.prompt ?? ""), 60)}` };
case "load_skill":
// SKILLS-SPEC §4.1 #4 — the trust line: the transcript always shows the moment a
// skill's instructions were picked up, model-invoked or forced via /skill.
return { pre: "Used skill: ", obj: String(a.name ?? "") };
case "ask_user":
return { pre: "Asked you a question" };
case "propose_plan":
@@ -131,6 +135,11 @@ export function humanizeApprovalTitle(name: string, args: any): HumanLine {
return a.title
? { pre: "Create the automation ", obj: `${trunc(String(a.title), 60)}` }
: { pre: "Create an automation" };
case "save_skill":
// SKILLS-SPEC §5.2/§7: "Add", never "install"; destination is "your skills".
return a.name
? { pre: "Add skill ", obj: String(a.name), post: " to your skills" }
: { pre: "Add a skill to your skills" };
default:
return { pre: `Use ${name}` };
}
+3
View File
@@ -33,6 +33,9 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
continue;
}
const user = userItemFromContent(m.content);
// Force-run (`/skill …`): `_display` holds the user's literal line; `content` carries
// the model-facing framing. Render what the user typed — one truthful bubble.
if (typeof m._display === "string" && m._display) user.text = m._display;
// `ts` (unix seconds) is the server's canonical-message stamp; older sessions have none.
if (typeof m.ts === "number") user.ts = m.ts;
if (user.text || user.attachments?.length) items.push(user);
+1 -1
View File
@@ -499,7 +499,7 @@ export function ProviderForm({
)}
{info && !info.needs_key && (
<p className="text-[11.5px] text-faint mt-2">
No API key needed Ollama runs models on this Mac.{" "}
No API key needed Ollama runs models on this computer.{" "}
<button
className="text-muted underline decoration-line underline-offset-2 hover:text-ink"
onClick={() => openExternal("https://ollama.com/download")}
+14
View File
@@ -397,6 +397,9 @@ body {
margin-top: 11px; font-family: var(--mono); font-size: 12px; color: var(--muted);
background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 8px 11px;
overflow-wrap: anywhere; white-space: pre-wrap; line-height: 1.55;
/* Long previews (a 170-line skill) must scroll INSIDE the card expanded content
otherwise outgrows the viewport with no way to read or reach "show less". */
max-height: 42vh; overflow-y: auto;
}
.approval-prev-more {
display: block; margin-top: 4px; font: inherit; font-family: -apple-system, sans-serif;
@@ -1603,3 +1606,14 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
fussy on a pill); small + high keeps the wordmark the star. */
vertical-align: 3px;
}
/* Folder artifact listing (a linked package dir renders as rows, not "not found"). */
.artifact-folderlist { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; }
.artifact-folder-row {
display: flex; align-items: center; gap: 9px; width: 100%; text-align: left;
padding: 7px 10px; border: 0; border-radius: 8px; background: none; cursor: pointer;
color: var(--ink); font-size: 12.5px;
}
.artifact-folder-row:hover { background: var(--paper); }
.artifact-folder-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.artifact-folder-size { font-size: 11px; color: var(--faint); }
+27
View File
@@ -175,6 +175,33 @@ def test_artifacts_list_and_read_previewable_files(tmp_path):
assert "<h1>Preview</h1>" in html["content"]
def test_artifact_read_folder_returns_listing(tmp_path):
"""A linked directory (e.g. a skill package dir) renders as a listing, never a dead
'not found' (owner report 2026-07-27). Dirs first, then files, sizes on files only."""
pkg = tmp_path / "directory-statistics"
pkg.mkdir()
(pkg / "SKILL.md").write_text("---\nname: x\n---\nbody", encoding="utf-8")
(pkg / "stats.py").write_text("print(1)", encoding="utf-8")
(pkg / "examples").mkdir()
client = _client(tmp_path, [])
res = client.get(
"/v1/sessions/unknown/artifacts/read", params={"path": "directory-statistics"}
).json()
assert res["ok"] is True and res["kind"] == "folder"
names = [e["name"] for e in res["entries"]]
assert names == ["examples", "SKILL.md", "stats.py"] # dirs first, then files by name
assert res["entries"][0]["dir"] is True
assert res["entries"][2]["size"] > 0
# A genuinely missing path keeps a friendly, non-jargon error.
missing = client.get(
"/v1/sessions/unknown/artifacts/read", params={"path": "nope.md"}
).json()
assert missing["ok"] is False
assert "moved or deleted" in missing["error"]
def test_artifact_read_rejects_path_escape(tmp_path):
client = _client(tmp_path, [])
escaped = client.get(
+361
View File
@@ -0,0 +1,361 @@
"""SKILLS-SPEC §4.6 — REST endpoints, session mutes over HTTP, WS force-run framing.
Follows the codebase's API convention: validation failures return ``{"ok": False,
"error": }`` bodies (not raw 4xx), matching every other /v1 management endpoint.
Engine integration runs on ScriptedProvider no LLM, no network.
"""
from __future__ import annotations
import base64
import io
import zipfile
import pytest
from fastapi.testclient import TestClient
from coworker.providers import AssistantTurn, ModelCapabilities, ProviderClient
from coworker.server import SessionManager, create_app
class ScriptedProvider(ProviderClient):
"""Queued turns + captured `messages` so tests can assert what the model saw."""
def __init__(self, turns=None):
self._turns = list(turns or [])
self.seen: list[list[dict]] = []
def complete(self, *, model, messages, tools=None, **settings):
self.seen.append(messages)
return self._turns.pop(0)
def capabilities(self, model):
return ModelCapabilities()
def _client(tmp_path, turns=None):
provider = ScriptedProvider(turns)
manager = SessionManager(workspace=tmp_path, provider=provider)
return TestClient(create_app(manager)), manager, provider
def _zip_b64(entries: dict[str, str]) -> str:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, content in entries.items():
zf.writestr(name, content)
return base64.b64encode(buf.getvalue()).decode()
GREET = {
"name": "greet",
"description": "says hello",
"instructions": "Say hello warmly.",
}
# -- CRUD -----------------------------------------------------------------------
def test_create_then_list_enriched(tmp_path):
client, _m, _p = _client(tmp_path)
assert client.post("/v1/skills", json=GREET).json()["ok"] is True
rows = client.get("/v1/skills").json()["skills"]
assert rows == [
{
"name": "greet",
"description": "says hello",
"instructions": "Say hello warmly.",
"scope": "global",
"source": "local",
"enabled": True,
"path": rows[0]["path"],
"files": 0,
}
]
def test_create_duplicate_and_blank_rejected(tmp_path):
client, _m, _p = _client(tmp_path)
client.post("/v1/skills", json=GREET)
dup = client.post("/v1/skills", json=GREET).json()
assert dup["ok"] is False and "already exists" in dup["error"]
for bad in (
{},
{"name": "", "instructions": "x"},
{"name": "ok-name", "instructions": " "},
):
res = client.post("/v1/skills", json=bad).json()
assert res["ok"] is False and res["error"]
def test_patch_edit_and_toggle(tmp_path):
client, _m, _p = _client(tmp_path)
client.post("/v1/skills", json=GREET)
assert (
client.patch(
"/v1/skills/greet", json={"description": "hi", "enabled": False}
).json()["ok"]
is True
)
row = client.get("/v1/skills").json()["skills"][0]
assert row["description"] == "hi" and row["enabled"] is False
unknown = client.patch("/v1/skills/ghost", json={"description": "x"}).json()
assert unknown["ok"] is False
def test_delete_and_unknown(tmp_path):
client, _m, _p = _client(tmp_path)
client.post("/v1/skills", json=GREET)
assert client.delete("/v1/skills/greet").json()["ok"] is True
assert client.get("/v1/skills").json()["skills"] == []
assert client.delete("/v1/skills/greet").json()["ok"] is False
def test_move_happy_and_collision(tmp_path):
client, _m, _p = _client(tmp_path)
ws = tmp_path / "proj"
ws.mkdir()
client.post("/v1/skills", json=GREET)
moved = client.post(
"/v1/skills/greet/move", json={"scope": "project", "workspace": str(ws)}
).json()
assert moved["ok"] is True and moved["skill"]["scope"] == "project"
client.post("/v1/skills", json=GREET) # recreate global → collision on move back
res = client.post(
"/v1/skills/greet/move", json={"scope": "global", "workspace": str(ws)}
).json()
assert res["ok"] is False and "already exists" in res["error"]
def test_create_project_scope_requires_real_workspace(tmp_path):
client, _m, _p = _client(tmp_path)
res = client.post(
"/v1/skills",
json={**GREET, "scope": "project", "workspace": str(tmp_path / "nope")},
).json()
assert res["ok"] is False and "workspace" in res["error"].lower()
def test_scratch_workspace_rejected_for_skill_writes(tmp_path):
"""A per-conversation scratch dir is not a project: create/move-into/confirm all refuse
it at the manager chokepoint; moving OUT of one still works (the rescue path)."""
client, manager, _p = _client(tmp_path)
scratch_base = tmp_path / "scratchpads"
manager.set_scratch_base(str(scratch_base))
scratch_ws = scratch_base / "6d57038c-50d"
(scratch_ws / ".coworker" / "skills").mkdir(parents=True)
res = client.post(
"/v1/skills",
json={**GREET, "scope": "project", "workspace": str(scratch_ws)},
).json()
assert res["ok"] is False and "temporary" in res["error"].lower()
client.post("/v1/skills", json=GREET) # global
res = client.post(
"/v1/skills/greet/move", json={"scope": "project", "workspace": str(scratch_ws)}
).json()
assert res["ok"] is False and "temporary" in res["error"].lower()
md = "---\nname: zipped\ndescription: d\n---\nbody\n"
preview = client.post(
"/v1/skills/upload", json={"data_b64": _zip_b64({"zipped/SKILL.md": md})}
).json()
res = client.post(
"/v1/skills/upload/confirm",
json={"token": preview["token"], "scope": "project", "workspace": str(scratch_ws)},
).json()
assert res["ok"] is False and "temporary" in res["error"].lower()
# Rescue path: a skill already stranded in scratch can still move OUT to global.
manager.skill_store.create(
name="stranded", description="", instructions="x",
scope="project", workspace=scratch_ws,
)
res = client.post(
"/v1/skills/stranded/move",
json={"scope": "global", "workspace": str(scratch_ws)},
).json()
assert res["ok"] is True and res["skill"]["scope"] == "global"
def test_path_traversal_names_rejected(tmp_path):
client, _m, _p = _client(tmp_path)
# ".." must be encoded — the HTTP client itself normalizes a literal /.. away.
assert client.delete("/v1/skills/%2e%2e").json()["ok"] is False
res = client.post("/v1/skills", json={**GREET, "name": "..%2Fevil"}).json()
assert res["ok"] is False
# -- upload + draft -----------------------------------------------------------------
def test_upload_preview_then_confirm(tmp_path):
client, _m, _p = _client(tmp_path)
md = "---\nname: greet\ndescription: says hello\n---\nSay hello.\n"
preview = client.post(
"/v1/skills/upload", json={"data_b64": _zip_b64({"greet/SKILL.md": md})}
).json()
assert preview["ok"] is True and preview["name"] == "greet"
assert client.get("/v1/skills").json()["skills"] == [] # preview installs nothing
confirmed = client.post(
"/v1/skills/upload/confirm", json={"token": preview["token"]}
).json()
assert confirmed["ok"] is True
row = client.get("/v1/skills").json()["skills"][0]
assert row["source"] == "uploaded"
def test_upload_invalid_archive_friendly(tmp_path):
client, _m, _p = _client(tmp_path)
bad = client.post(
"/v1/skills/upload",
json={"data_b64": base64.b64encode(b"not a zip").decode(), "filename": "x.zip"},
).json()
assert bad["ok"] is False and "zip" in bad["error"].lower()
# A bare .md without frontmatter gets the md-specific guidance.
bare = client.post(
"/v1/skills/upload",
json={"data_b64": base64.b64encode(b"no frontmatter").decode(), "filename": "a.md"},
).json()
assert bare["ok"] is False and "frontmatter" in bare["error"].lower()
assert client.post("/v1/skills/upload", json={}).json()["ok"] is False
assert (
client.post("/v1/skills/upload", json={"data_b64": "!!!"}).json()["ok"] is False
)
def test_draft_endpoint_is_gone(tmp_path):
"""The drafting path retired with the worker-authors flow (SKILLS-SPEC §5.2/§9):
creation is a conversation ending in save_skill, not a Settings endpoint."""
client, _m, _p = _client(tmp_path)
# 405 not 404: the path now falls through to PATCH /v1/skills/{name}. Either way,
# POSTing a draft is no longer a thing.
assert client.post("/v1/skills/draft", json={"description": "x"}).status_code in (404, 405)
# -- session mutes over HTTP ----------------------------------------------------------
def test_session_mute_roundtrip(tmp_path):
client, _m, _p = _client(tmp_path)
client.post("/v1/skills", json=GREET)
view = client.get("/v1/sessions/s1/skills").json()["skills"]
assert view == [
{"name": "greet", "description": "says hello", "scope": "global", "enabled": True}
]
after = client.post(
"/v1/sessions/s1/skills", json={"skill": "greet", "enabled": False}
).json()["skills"]
assert after[0]["enabled"] is False
other = client.get("/v1/sessions/s2/skills").json()["skills"]
assert other[0]["enabled"] is True # mute is per-session
cleared = client.post(
"/v1/sessions/s1/skills", json={"skill": "greet", "clear": True}
).json()["skills"]
assert cleared[0]["enabled"] is True
assert (
client.post("/v1/sessions/s1/skills", json={}).json()["ok"] is False
) # null input
# -- engine integration (ScriptedProvider, no LLM) --------------------------------------
def test_engine_catalog_respects_settings_disable(tmp_path):
client, manager, _p = _client(tmp_path)
client.post("/v1/skills", json=GREET)
client.post(
"/v1/skills", json={"name": "hidden", "description": "off", "instructions": "x"}
)
client.patch("/v1/skills/hidden", json={"enabled": False})
from coworker.agent import build_engine
from coworker.agents.registry import get_agent
engine = build_engine(
agent=get_agent("chat"),
provider=ScriptedProvider(),
skill_filter=lambda: manager.effective_skill_names("s1"),
)
# The menu rides the live per-turn context block (§4.1), not the system prompt.
menu = engine.context_provider()
assert "greet" in menu
assert "hidden" not in menu
assert "greet" not in engine.messages[0]["content"]
def _drain(ws):
types = []
while True:
evt = ws.receive_json()
types.append(evt["type"])
if evt["type"] in {"turn_done", "input_rejected"}:
return types, evt
def test_ws_force_run_frames_the_turn(tmp_path):
"""The display/model split (§4.1 #3): the provider gets the framing; the transcript
(TURN_START + the persisted message's `_display`) gets the user's literal '/name …'."""
client, _m, provider = _client(tmp_path, [AssistantTurn(text="done")])
client.post("/v1/skills", json=GREET)
with client.websocket_connect("/ws/session/s1?agent=chat") as ws:
assert ws.receive_json()["type"] == "ready"
ws.send_json({"type": "user_message", "text": "hello", "skill": "greet"})
events = []
while True:
evt = ws.receive_json()
events.append(evt)
if evt["type"] == "turn_done":
break
framed = provider.seen[-1][-1]["content"]
assert 'load_skill("greet")' in str(framed)
assert "hello" in str(framed)
start = next(e for e in events if e["type"] == "turn_start")
assert start["data"]["display"] == "/greet hello" # what the transcript shows
assert "load_skill" in str(start["data"]["input"]) # what the model saw
stored = client.get("/v1/sessions/s1/messages").json()["messages"]
user = next(m for m in stored if m["role"] == "user")
assert user["_display"] == "/greet hello"
assert "load_skill" in str(user["content"])
def test_ws_force_run_unknown_and_muted_error_without_killing_socket(tmp_path):
client, _m, provider = _client(tmp_path, [AssistantTurn(text="ok")])
client.post("/v1/skills", json=GREET)
client.post("/v1/sessions/s1/skills", json={"skill": "greet", "enabled": False})
with client.websocket_connect("/ws/session/s1?agent=chat") as ws:
assert ws.receive_json()["type"] == "ready"
# unknown skill → visible rejection, no turn
ws.send_json({"type": "user_message", "text": "x", "skill": "ghost"})
evt = ws.receive_json()
assert evt["type"] == "input_rejected"
assert "not available" in evt["data"]["error"]
# muted skill → same rejection (§4.6 #15: no silent auto-unmute)
ws.send_json({"type": "user_message", "text": "x", "skill": "greet"})
evt = ws.receive_json()
assert evt["type"] == "input_rejected"
# empty name → invalid frame
ws.send_json({"type": "user_message", "text": "x", "skill": " "})
assert ws.receive_json()["type"] == "input_rejected"
# socket still healthy: a normal message runs a turn
ws.send_json({"type": "user_message", "text": "plain"})
types, _ = _drain(ws)
assert "turn_done" in types
# No force-run framing ever reached the model (the catalog line in the system prompt
# legitimately mentions load_skill — the invariant is about USER messages only).
users = [
str(m.get("content"))
for msgs in provider.seen
for m in msgs
if m.get("role") == "user"
]
assert all("Use the skill" not in u for u in users)
assert any("plain" in u for u in users)
def test_reveal_unknown_skill_is_a_friendly_error(tmp_path):
client, _m, _p = _client(tmp_path)
res = client.post("/v1/skills/nope/reveal", json={}).json()
assert res["ok"] is False and "nope" in res["error"]
+265
View File
@@ -0,0 +1,265 @@
"""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.registry import get_agent
_skill(manager.skill_store.global_dir, "early", body="early body")
engine = build_engine(
agent=get_agent("chat"),
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.registry import get_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=get_agent("chat"),
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
+412
View File
@@ -0,0 +1,412 @@
"""SKILLS-SPEC §4.6 — SkillStore: folder-backed CRUD, parsing edges, staged uploads.
Scope = folder location (folder-is-truth). These tests pin the store's safety rails:
skill names become folder names (traversal guards), uploads are staged and previewed
before anything lands in a scope dir, and disable state is personal (settings JSON,
never a marker committed with a project folder).
"""
from __future__ import annotations
import io
import json
import os
import zipfile
from pathlib import Path
import pytest
from coworker.skills import SkillLoader, SkillStore, validate_name
@pytest.fixture()
def store(tmp_path):
return SkillStore(global_dir=tmp_path / "global-skills")
@pytest.fixture()
def workspace(tmp_path):
ws = tmp_path / "proj"
ws.mkdir()
return ws
def _zip_bytes(entries: dict[str, str]) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, content in entries.items():
zf.writestr(name, content)
return buf.getvalue()
SKILL_MD = "---\nname: greet\ndescription: says hello\n---\n\nSay hello warmly.\n"
# -- create ----------------------------------------------------------------------
def test_create_global_roundtrip(store):
created = store.create(
name="weekly-report",
description="Monday status report",
instructions="1. Gather updates\n2. Write the report",
)
assert created["scope"] == "global"
loader = SkillLoader([store.global_dir])
skill = loader.get("weekly-report")
assert skill.description == "Monday status report"
assert "Gather updates" in skill.instructions
def test_create_project_scoped(store, workspace):
store.create(
name="release-checklist",
description="repo release steps",
instructions="Run the checklist.",
scope="project",
workspace=workspace,
)
md = workspace / ".coworker" / "skills" / "release-checklist" / "SKILL.md"
assert md.is_file()
def test_create_duplicate_rejected(store):
store.create(name="dup", description="", instructions="x")
with pytest.raises(ValueError, match="already exists"):
store.create(name="dup", description="", instructions="y")
@pytest.mark.parametrize(
"bad",
["", " ", "a" * 65, "../evil", "a/b", "a\\b", ".hidden", "café"],
)
def test_invalid_names_rejected(bad):
with pytest.raises(ValueError):
validate_name(bad)
def test_blank_instructions_rejected(store):
with pytest.raises(ValueError, match="instructions"):
store.create(name="empty", description="d", instructions=" ")
# -- update / delete / move --------------------------------------------------------
def test_update_preserves_resources(store):
store.create(name="tpl", description="v1", instructions="old body")
extra = store.global_dir / "tpl" / "template.txt"
extra.write_text("keep me", encoding="utf-8")
store.update("tpl", instructions="new body")
loader = SkillLoader([store.global_dir])
assert loader.get("tpl").instructions == "new body"
assert loader.get("tpl").description == "v1" # untouched field survives
assert extra.read_text(encoding="utf-8") == "keep me"
def test_delete_and_unknown(store):
store.create(name="gone", description="", instructions="x")
store.delete("gone")
assert not (store.global_dir / "gone").exists()
with pytest.raises(ValueError, match="Unknown skill"):
store.delete("gone")
def test_delete_symlinked_folder_not_followed(store, tmp_path):
outside = tmp_path / "outside"
outside.mkdir()
(outside / "SKILL.md").write_text(SKILL_MD, encoding="utf-8")
store.global_dir.mkdir(parents=True, exist_ok=True)
try:
os.symlink(outside, store.global_dir / "greet", target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks unavailable on this platform/user")
# Either refused (escape guard) or unlinked in place — the target must survive.
try:
store.delete("greet")
except ValueError:
pass
assert (outside / "SKILL.md").is_file()
def test_move_roundtrip(store, workspace):
store.create(name="mover", description="", instructions="x")
moved = store.move("mover", to_scope="project", workspace=workspace)
assert moved["scope"] == "project"
assert (workspace / ".coworker" / "skills" / "mover" / "SKILL.md").is_file()
assert not (store.global_dir / "mover").exists()
store.move("mover", to_scope="global", workspace=workspace)
assert (store.global_dir / "mover" / "SKILL.md").is_file()
def test_move_collision_leaves_source(store, workspace):
store.create(name="both", description="global copy", instructions="g")
store.create(
name="both",
description="project copy",
instructions="p",
scope="project",
workspace=workspace,
)
with pytest.raises(ValueError, match="already exists"):
store.move("both", to_scope="global", workspace=workspace)
# most-local find() → the project copy was the move source and it survives
assert (workspace / ".coworker" / "skills" / "both" / "SKILL.md").is_file()
# -- parsing edges (null/malformed input never crashes) -----------------------------
def _manual_skill(base: Path, folder: str, text: str) -> None:
d = base / folder
d.mkdir(parents=True)
(d / "SKILL.md").write_text(text, encoding="utf-8")
def test_no_frontmatter_falls_back_to_folder_name(store):
_manual_skill(store.global_dir, "bare", "Just instructions, no frontmatter.")
rows = store.rows()
assert rows[0]["name"] == "bare"
assert rows[0]["description"] == ""
def test_unterminated_frontmatter_no_crash(store):
_manual_skill(store.global_dir, "broken", "---\nname: broken\nno closing fence")
rows = store.rows()
assert rows[0]["name"] == "broken"
def test_empty_skill_md_no_crash(store):
_manual_skill(store.global_dir, "hollow", "")
rows = store.rows()
assert rows[0]["name"] == "hollow"
assert rows[0]["enabled"] is True
def test_unicode_content_and_crlf_roundtrip(store):
_manual_skill(
store.global_dir,
"emoji",
"---\r\nname: emoji\r\ndescription: says 你好 🎉\r\n---\r\n\r\nGreet with 🎉.\r\n",
)
loader = SkillLoader([store.global_dir])
skill = loader.get("emoji")
assert "🎉" in skill.description
assert "你好" in skill.description
def test_frontmatter_name_wins_and_keys_collisions(store, workspace):
store.create(name="brand", description="global copy", instructions="g")
_manual_skill(
workspace / ".coworker" / "skills",
"other-folder",
"---\nname: brand\ndescription: project copy\n---\nbody",
)
rows = store.rows(workspace)
brand = [r for r in rows if r["name"] == "brand"]
assert len(brand) == 1 # one row per name, not per folder
assert brand[0]["scope"] == "project" # project copy shadows global
# -- uploads -----------------------------------------------------------------------
def test_upload_zip_at_root_and_nested(store):
for entries in (
{"SKILL.md": SKILL_MD},
{"greet/SKILL.md": SKILL_MD, "greet/notes.txt": "extra"},
):
preview = store.stage_upload(_zip_bytes(entries))
assert preview["name"] == "greet"
assert preview["description"] == "says hello"
store.discard_upload(preview["token"])
def test_upload_without_skill_md_rejected(store):
with pytest.raises(ValueError, match="SKILL.md"):
store.stage_upload(_zip_bytes({"readme.txt": "not a skill"}))
# A broken file that CLAIMS to be an archive fails as an archive, not as markdown.
with pytest.raises(ValueError, match="zip"):
store.stage_upload(b"garbage bytes", filename="broken.zip")
# Binary junk with no extension hint → the catch-all names both accepted shapes.
with pytest.raises(ValueError, match=r"\.zip or a SKILL\.md"):
store.stage_upload(b"\xff\xfe\x00\x01binary junk")
def test_upload_bare_md_with_frontmatter(store):
preview = store.stage_upload(SKILL_MD.encode(), filename="greet.md")
assert preview["name"] == "greet"
assert preview["files"] == []
saved = store.confirm_upload(preview["token"], scope="global")
assert saved["name"] == "greet"
assert store.rows()[0]["source"] == "uploaded"
def test_upload_bare_md_without_name_rejected(store):
with pytest.raises(ValueError, match="frontmatter"):
store.stage_upload(b"Just instructions, no frontmatter.", filename="notes.md")
def test_upload_mac_finder_zip_junk_stripped(store):
"""macOS Finder's Compress injects __MACOSX/._* shadows and .DS_Store — a Mac-made
zip must install clean on Windows/Linux, with none of that staged or listed."""
preview = store.stage_upload(
_zip_bytes(
{
"greet/SKILL.md": SKILL_MD,
"greet/notes.txt": "real resource",
"greet/.DS_Store": "junk",
"__MACOSX/greet/._SKILL.md": "junk",
"__MACOSX/greet/._notes.txt": "junk",
}
),
filename="greet.zip",
)
assert preview["name"] == "greet"
assert preview["files"] == ["notes.txt"] # junk neither listed…
saved = store.confirm_upload(preview["token"], scope="global")
folder = Path(saved["path"])
installed = sorted(p.name for p in folder.rglob("*"))
assert installed == ["SKILL.md", "notes.txt"] # …nor installed
def test_upload_zip_slip_rejected(store, tmp_path):
with pytest.raises(ValueError, match="unsafe"):
store.stage_upload(_zip_bytes({"../evil/SKILL.md": SKILL_MD}))
assert not (tmp_path / "evil").exists()
def test_upload_confirm_saves_previewed_content(store):
preview = store.stage_upload(
_zip_bytes({"greet/SKILL.md": SKILL_MD, "greet/notes.txt": "extra"})
)
saved = store.confirm_upload(preview["token"], scope="global")
assert saved["name"] == "greet"
loader = SkillLoader([store.global_dir])
assert loader.get("greet").description == preview["description"]
assert (store.global_dir / "greet" / "notes.txt").is_file()
rows = store.rows()
assert rows[0]["source"] == "uploaded" # provenance stamped (SKILLS-SPEC v2 hook)
with pytest.raises(ValueError, match="expired"):
store.confirm_upload(preview["token"]) # token is one-shot
# -- disable state -------------------------------------------------------------------
def test_disable_persists_across_reload(store, monkeypatch, tmp_path):
store.create(name="sleepy", description="", instructions="x")
store.set_enabled("sleepy", False)
reloaded = SkillStore(global_dir=store.global_dir)
assert "sleepy" in reloaded.disabled_names()
assert reloaded.rows()[0]["enabled"] is False
reloaded.set_enabled("sleepy", True)
assert reloaded.rows()[0]["enabled"] is True
def test_corrupt_settings_json_treated_as_empty(store):
store._settings_path.parent.mkdir(parents=True, exist_ok=True)
store._settings_path.write_text("{not json", encoding="utf-8")
assert store.disabled_names() == set()
store.set_enabled("x", False) # recovers by rewriting the file
assert store.disabled_names() == {"x"}
# -- save_skill tool (SKILLS-SPEC §5.2 — the worker-authors door) -------------------
from coworker.skills import save_skill_tool # noqa: E402
@pytest.fixture()
def session_dir(tmp_path):
d = tmp_path / "session-root"
d.mkdir()
return d
def test_save_skill_adds_a_new_global_skill(store, session_dir):
tool = save_skill_tool(store, allowed_dirs=[session_dir])
result = tool(
name="weekly-report",
description="Monday status report",
instructions="1. Gather updates\n2. Write the report",
)
assert result["ok"] and result["action"] == "added"
skill = SkillLoader([store.global_dir]).get("weekly-report")
assert skill.description == "Monday status report"
def test_save_skill_bundles_files_from_session_roots(store, session_dir):
script = session_dir / "fetch_prs.py"
script.write_text("print('prs')", encoding="utf-8")
example = session_dir / "sub" / "example-report.md"
example.parent.mkdir()
example.write_text("# Example", encoding="utf-8")
tool = save_skill_tool(store, allowed_dirs=[session_dir])
result = tool(
name="gh-report",
description="report",
instructions="Run fetch_prs.py",
files=[str(script), "sub/example-report.md"], # absolute AND relative both work
)
assert result["ok"] and sorted(result["files"]) == ["example-report.md", "fetch_prs.py"]
folder = store.global_dir / "gh-report"
assert (folder / "fetch_prs.py").read_text(encoding="utf-8") == "print('prs')"
assert (folder / "example-report.md").is_file()
def test_save_skill_existing_name_updates_and_keeps_resources(store, session_dir):
store.create(name="gh-report", description="old", instructions="old body")
(store.global_dir / "gh-report" / "keep.txt").write_text("keep", encoding="utf-8")
tool = save_skill_tool(store, allowed_dirs=[session_dir])
result = tool(name="gh-report", description="new", instructions="new body")
assert result["ok"] and result["action"] == "updated"
skill = SkillLoader([store.global_dir]).get("gh-report")
assert skill.description == "new" and "new body" in skill.instructions
assert (store.global_dir / "gh-report" / "keep.txt").is_file() # siblings preserved
def test_save_skill_refuses_files_outside_session_roots(store, session_dir, tmp_path):
secret = tmp_path / "outside.txt"
secret.write_text("secret", encoding="utf-8")
tool = save_skill_tool(store, allowed_dirs=[session_dir])
result = tool(name="x", description="d", instructions="i", files=[str(secret)])
assert "outside this session's folders" in result["error"]
assert not (store.global_dir / "x").exists() # vetting happens BEFORE any disk write
def test_save_skill_validation_errors(store, session_dir):
tool = save_skill_tool(store, allowed_dirs=[session_dir])
assert "description" in tool(name="x", description=" ", instructions="i")["error"]
assert "instructions" in tool(name="x", description="d", instructions=" ")["error"]
assert "error" in tool(name="../evil", description="d", instructions="i")
# A bundled SKILL.md is skipped silently, never an error: the instructions argument
# becomes SKILL.md, and models routinely try to bundle their workspace draft of it —
# erroring cost a second approval round (live drive 2026-07-27).
(session_dir / "SKILL.md").write_text("x", encoding="utf-8")
result = tool(name="x", description="d", instructions="i", files=["SKILL.md"])
assert result["ok"] and result["files"] == []
skill_md = (store.global_dir / "x" / "SKILL.md").read_text(encoding="utf-8")
assert "i" in skill_md and "x" != skill_md # instructions won, draft file ignored
def test_save_skill_requires_approval_metadata(store):
tool = save_skill_tool(store)
meta = tool.__aisuite_tool_metadata__
assert meta.requires_approval is True # → EXTERNAL risk → approval card, every call
assert tool.__coworker_schema__["function"]["name"] == "save_skill"
required = tool.__coworker_schema__["function"]["parameters"]["required"]
assert required == ["name", "description", "instructions"]
def test_rows_report_bundled_file_count(store):
store.create(name="plain", description="d", instructions="i")
store.create(name="rich", description="d", instructions="i")
rich = store.global_dir / "rich"
(rich / "fetch.py").write_text("x", encoding="utf-8")
(rich / "examples").mkdir()
(rich / "examples" / "one.md").write_text("x", encoding="utf-8")
by_name = {r["name"]: r for r in store.rows()}
assert by_name["plain"]["files"] == 0 # SKILL.md itself is not "bundled"
assert by_name["rich"]["files"] == 2 # counted recursively