mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-14 00:00:58 +00:00
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:
+77
-6
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 [
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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}` };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user