mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-14 08:04:32 +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 __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from .agents import Agent, AgentContext, code_agent
|
from .agents import Agent, AgentContext, code_agent
|
||||||
from .automation import scheduling_tools
|
from .automation import scheduling_tools
|
||||||
@@ -30,7 +30,7 @@ from .roots import RootDir, normalize_roots, render_context
|
|||||||
from .providers import ProviderClient, ProviderRouter
|
from .providers import ProviderClient, ProviderRouter
|
||||||
from .overrides import RiskOverrideStore
|
from .overrides import RiskOverrideStore
|
||||||
from .secrets import SecretStore, state_dir
|
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 import ToolRegistry
|
||||||
from .tools.ask import ask_user_tool
|
from .tools.ask import ask_user_tool
|
||||||
from .tools.directories import request_directory_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
|
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]:
|
def _skill_dirs(workspace: Optional[Path]) -> list[Path]:
|
||||||
dirs = [state_dir() / "skills"]
|
dirs = [state_dir() / "skills"]
|
||||||
if workspace is not None:
|
if workspace is not None:
|
||||||
@@ -133,6 +165,8 @@ def build_engine(
|
|||||||
channel_buffer: Optional[Any] = None,
|
channel_buffer: Optional[Any] = None,
|
||||||
routing_targets: Optional[list[str]] = None,
|
routing_targets: Optional[list[str]] = None,
|
||||||
connector_filter: Optional[set[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:
|
) -> TurnEngine:
|
||||||
ws = Path(workspace).expanduser().resolve() if workspace else None
|
ws = Path(workspace).expanduser().resolve() if workspace else None
|
||||||
if agent.needs_workspace and ws is None:
|
if agent.needs_workspace and ws is None:
|
||||||
@@ -260,10 +294,22 @@ def build_engine(
|
|||||||
instructions = f"{instructions}\n\n{block}"
|
instructions = f"{instructions}\n\n{block}"
|
||||||
|
|
||||||
skill_loader = SkillLoader(_skill_dirs(ws))
|
skill_loader = SkillLoader(_skill_dirs(ws))
|
||||||
registry.register_all(skill_tools(skill_loader))
|
# Per-session effective menu (SKILLS-SPEC §3). The manager passes a CALLABLE so
|
||||||
catalog = skill_catalog_text(skill_loader)
|
# load_skill consults the LIVE state per call (a Settings disable applies to running
|
||||||
if catalog:
|
# sessions; a skill created after this build is still loadable). The catalog itself
|
||||||
instructions = f"{instructions}\n\n{catalog}"
|
# 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 →
|
# 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).
|
# no-op; never written by persona loading (the no-self-grant rule).
|
||||||
@@ -294,6 +340,10 @@ def build_engine(
|
|||||||
else None
|
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:
|
def context_provider() -> str:
|
||||||
parts = []
|
parts = []
|
||||||
if permissions.mode is Mode.PLAN:
|
if permissions.mode is Mode.PLAN:
|
||||||
@@ -304,6 +354,26 @@ def build_engine(
|
|||||||
ctx = roots_context()
|
ctx = roots_context()
|
||||||
if ctx:
|
if ctx:
|
||||||
parts.append(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)
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
engine = TurnEngine(
|
engine = TurnEngine(
|
||||||
@@ -336,6 +406,7 @@ def build_engine(
|
|||||||
"workspace": str(ws) if ws else "",
|
"workspace": str(ws) if ws else "",
|
||||||
}
|
}
|
||||||
engine.skill_loader = skill_loader # type: ignore[attr-defined]
|
engine.skill_loader = skill_loader # type: ignore[attr-defined]
|
||||||
|
_engine_box.append(engine) # late-bind for the countermand (see context_provider)
|
||||||
return engine
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ ABOUT: dict[str, str] = {
|
|||||||
"Multiple accounts connect side by side.",
|
"Multiple accounts connect side by side.",
|
||||||
"monday": "Work with your monday.com boards — read items, summarize and "
|
"monday": "Work with your monday.com boards — read items, summarize and "
|
||||||
"aggregate board data, create items, and post updates. One-click sign-in "
|
"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.",
|
"get a small curated set of its tools, never the full catalog.",
|
||||||
"asana": "Keep up with your Asana work — search and read tasks and "
|
"asana": "Keep up with your Asana work — search and read tasks and "
|
||||||
"projects, create tasks, and comment. Connects with a personal access "
|
"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).
|
# doesn't carry (e.g. "calendar" must surface Outlook, not just Google Calendar).
|
||||||
aliases: tuple = ()
|
aliases: tuple = ()
|
||||||
# Vendor-hosted MCP server URL → this connector is MCP-BACKED: one-click connect
|
# 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>`),
|
# 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).
|
# 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
|
# A connector may carry BOTH mcp_url and manual fields (jira): the profile's
|
||||||
@@ -704,7 +704,7 @@ DESCRIPTORS: list[ConnectorDescriptor] = [
|
|||||||
fields=[],
|
fields=[],
|
||||||
instructions=[
|
instructions=[
|
||||||
"One click connects via monday.com sign-in in your browser.",
|
"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,
|
available=True,
|
||||||
),
|
),
|
||||||
|
|||||||
+13
-2
@@ -163,13 +163,20 @@ class TurnEngine:
|
|||||||
|
|
||||||
# -- main loop --------------------------------------------------------------
|
# -- main loop --------------------------------------------------------------
|
||||||
async def run(
|
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]:
|
) -> AsyncIterator[Event]:
|
||||||
# `user_input` is a string, or OpenAI content-parts (text + image_url) for attachments.
|
# `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
|
# `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
|
# 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.
|
# 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] = {
|
message: dict[str, Any] = {
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": user_input,
|
"content": user_input,
|
||||||
@@ -177,11 +184,15 @@ class TurnEngine:
|
|||||||
}
|
}
|
||||||
if source is not None:
|
if source is not None:
|
||||||
message["source"] = source
|
message["source"] = source
|
||||||
|
if display is not None:
|
||||||
|
message["_display"] = display
|
||||||
self.messages.append(message)
|
self.messages.append(message)
|
||||||
self._cancel.clear()
|
self._cancel.clear()
|
||||||
data: dict[str, Any] = {"input": user_input}
|
data: dict[str, Any] = {"input": user_input}
|
||||||
if source is not None:
|
if source is not None:
|
||||||
data["source"] = source
|
data["source"] = source
|
||||||
|
if display is not None:
|
||||||
|
data["display"] = display
|
||||||
yield Event(EventType.TURN_START, data)
|
yield Event(EventType.TURN_START, data)
|
||||||
async for event in self._loop():
|
async for event in self._loop():
|
||||||
yield event
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -388,6 +390,29 @@ def create_app(manager: SessionManager) -> FastAPI:
|
|||||||
manager.unattended.set(session_id, on)
|
manager.unattended.set(session_id, on)
|
||||||
return {"ok": True, "session_id": session_id, "unattended": 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")
|
@app.get("/v1/sessions/{session_id}/connections")
|
||||||
def session_connections(session_id: str, persona: str = "") -> dict[str, Any]:
|
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
|
# `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")
|
@app.get("/v1/skills")
|
||||||
def skills() -> dict[str, Any]:
|
def skills(workspace: str = "") -> dict[str, Any]:
|
||||||
return {"skills": manager.list_skills()}
|
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")
|
@app.get("/v1/workspaces/recent")
|
||||||
def recent_workspaces() -> dict[str, Any]:
|
def recent_workspaces() -> dict[str, Any]:
|
||||||
@@ -1729,11 +1791,15 @@ def create_app(manager: SessionManager) -> FastAPI:
|
|||||||
"iteration_end",
|
"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.
|
# The receive loop atomically claims this session before scheduling the task.
|
||||||
# Keeping the claim outside prevents two back-to-back frames from both starting.
|
# Keeping the claim outside prevents two back-to-back frames from both starting.
|
||||||
try:
|
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:
|
async for event in events:
|
||||||
# Broadcast to every socket viewing this session (this socket included — it's a
|
# 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.
|
# 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.
|
# or flush an in-progress assistant stream in the GUI.
|
||||||
await ws.send_json({"type": "input_rejected", "data": {"error": reason}})
|
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):
|
if not manager.try_mark_running(session_id):
|
||||||
await reject_input(
|
await reject_input(
|
||||||
"This session is already running a turn. Wait for it to finish or stop it."
|
"This session is already running a turn. Wait for it to finish or stop it."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
asyncio.create_task(run_turn(content, retry=retry))
|
asyncio.create_task(run_turn(content, retry=retry, display=display))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -1918,10 +1984,34 @@ def create_app(manager: SessionManager) -> FastAPI:
|
|||||||
if model is not None and not isinstance(model, str):
|
if model is not None and not isinstance(model, str):
|
||||||
await reject_input("Invalid model: expected a string.")
|
await reject_input("Invalid model: expected a string.")
|
||||||
continue
|
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)
|
await _apply_model(model)
|
||||||
if text or attachments:
|
if text or attachments:
|
||||||
content = build_user_content(text, attachments)
|
content = build_user_content(text, attachments)
|
||||||
await claim_turn(content=content)
|
await claim_turn(content=content, display=display)
|
||||||
else:
|
else:
|
||||||
await reject_input(f"Unknown WebSocket message type: {kind}.")
|
await reject_input(f"Unknown WebSocket message type: {kind}.")
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
|||||||
+217
-11
@@ -82,7 +82,12 @@ from ..providers import (
|
|||||||
)
|
)
|
||||||
from ..secrets import SecretStore, state_dir
|
from ..secrets import SecretStore, state_dir
|
||||||
from ..sessions import SessionRecord
|
from ..sessions import SessionRecord
|
||||||
from ..skills import SkillLoader
|
from ..skills import (
|
||||||
|
SessionSkillStore,
|
||||||
|
SkillLoader,
|
||||||
|
SkillStore,
|
||||||
|
effective_skills,
|
||||||
|
)
|
||||||
|
|
||||||
_SCOPES = {s.value for s in Scope}
|
_SCOPES = {s.value for s in Scope}
|
||||||
|
|
||||||
@@ -225,6 +230,11 @@ class SessionManager:
|
|||||||
self.session_connections = SessionConnectionStore(
|
self.session_connections = SessionConnectionStore(
|
||||||
base / "session_connections.json"
|
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
|
# Dead-letter: inbound messages with no destination + background-turn failures, so neither
|
||||||
# vanishes silently (a debugging/visibility surface, not a redelivery queue).
|
# vanishes silently (a debugging/visibility surface, not a redelivery queue).
|
||||||
self.unrouted = UnroutedStore(base / "unrouted.json")
|
self.unrouted = UnroutedStore(base / "unrouted.json")
|
||||||
@@ -454,6 +464,9 @@ class SessionManager:
|
|||||||
routing_targets=self._routing_targets(session_id, agent),
|
routing_targets=self._routing_targets(session_id, agent),
|
||||||
# Per-session connection hierarchy: expose only effective-enabled connectors' tools.
|
# Per-session connection hierarchy: expose only effective-enabled connectors' tools.
|
||||||
connector_filter=self.effective_connectors(session_id, agent_name),
|
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
|
# 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.
|
# 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
|
MAX_BINARY_PREVIEW = 25 * 1024 * 1024 # base64-over-JSON gets heavy past this
|
||||||
|
|
||||||
def _artifact_target(
|
def _artifact_target(
|
||||||
self, session_id: str, path: str
|
self, session_id: str, path: str, *, allow_dir: bool = False
|
||||||
) -> tuple[Optional[Path], Optional[str]]:
|
) -> tuple[Optional[Path], Optional[str]]:
|
||||||
"""Resolve an artifact path under the session's workspace, or (None, error)."""
|
"""Resolve an artifact path under the session's workspace, or (None, error)."""
|
||||||
record = self.session_store.load(session_id)
|
record = self.session_store.load(session_id)
|
||||||
@@ -1303,14 +1316,36 @@ class SessionManager:
|
|||||||
target.relative_to(root)
|
target.relative_to(root)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None, "path escapes workspace"
|
return None, "path escapes workspace"
|
||||||
|
if allow_dir and target.is_dir():
|
||||||
|
return target, None
|
||||||
if not target.is_file():
|
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
|
return target, None
|
||||||
|
|
||||||
def read_artifact(self, session_id: str, path: str) -> dict[str, Any]:
|
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:
|
if target is None:
|
||||||
return {"ok": False, "error": err}
|
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)
|
kind = _artifact_kind(target)
|
||||||
if kind == "office":
|
if kind == "office":
|
||||||
# PowerPoint/Word binaries can't be previewed inline; the UI offers
|
# PowerPoint/Word binaries can't be previewed inline; the UI offers
|
||||||
@@ -1364,27 +1399,29 @@ class SessionManager:
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
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:
|
if target is None:
|
||||||
return {"ok": False, "error": err}
|
return {"ok": False, "error": err}
|
||||||
|
# A folder "opens" as itself in the file manager, whatever the mode.
|
||||||
|
is_dir = target.is_dir()
|
||||||
try:
|
try:
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
args = (
|
args = (
|
||||||
["open", "-R", str(target)]
|
["open", "-R", str(target)]
|
||||||
if mode == "reveal"
|
if mode == "reveal" and not is_dir
|
||||||
else ["open", str(target)]
|
else ["open", str(target)]
|
||||||
)
|
)
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||||
)
|
)
|
||||||
elif sys.platform == "win32":
|
elif sys.platform == "win32":
|
||||||
if mode == "reveal":
|
if mode == "reveal" and not is_dir:
|
||||||
# Explorer wants the path glued to the switch: /select,<path>
|
# Explorer wants the path glued to the switch: /select,<path>
|
||||||
subprocess.Popen(["explorer", f"/select,{target}"])
|
subprocess.Popen(["explorer", f"/select,{target}"])
|
||||||
else:
|
else:
|
||||||
os.startfile(str(target)) # type: ignore[attr-defined] # open in default app
|
os.startfile(str(target)) # type: ignore[attr-defined] # open in default app
|
||||||
else: # Linux/BSD
|
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(
|
subprocess.Popen(
|
||||||
["xdg-open", tgt],
|
["xdg-open", tgt],
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
@@ -2700,6 +2737,9 @@ class SessionManager:
|
|||||||
# Scheduled runs respect the same per-session connection hierarchy as live sessions:
|
# Scheduled runs respect the same per-session connection hierarchy as live sessions:
|
||||||
# expose only the persona's effective-enabled connectors' tools (§4.3).
|
# expose only the persona's effective-enabled connectors' tools (§4.3).
|
||||||
connector_filter=self.effective_connectors(session_id, task.agent),
|
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)
|
self._seed_task_permissions(engine, task)
|
||||||
return engine
|
return engine
|
||||||
@@ -3683,6 +3723,8 @@ class SessionManager:
|
|||||||
self.mention_sessions.remove_session(session_id)
|
self.mention_sessions.remove_session(session_id)
|
||||||
# ...and drops its per-session connector overrides (§4.2, like subscriptions).
|
# ...and drops its per-session connector overrides (§4.2, like subscriptions).
|
||||||
self.session_connections.remove_session(session_id)
|
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
|
# ...and closes its pending Inbox items — an orphaned approval/question can never be
|
||||||
# meaningfully answered (owner call, 2026-07-03).
|
# meaningfully answered (owner call, 2026-07-03).
|
||||||
self.inbox.resolve_session(session_id)
|
self.inbox.resolve_session(session_id)
|
||||||
@@ -3758,9 +3800,173 @@ class SessionManager:
|
|||||||
def list_agents(self) -> list[dict[str, Any]]:
|
def list_agents(self) -> list[dict[str, Any]]:
|
||||||
return _list_agents()
|
return _list_agents()
|
||||||
|
|
||||||
def list_skills(self) -> list[dict[str, Any]]:
|
# -- skills (SKILLS-SPEC §4.4) ------------------------------------------------
|
||||||
loader = SkillLoader([state_dir() / "skills"])
|
def list_skills(self, workspace: Optional[str] = None) -> list[dict[str, Any]]:
|
||||||
return loader.catalog()
|
"""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]]:
|
def list_memory(self) -> list[dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -1,3 +1,20 @@
|
|||||||
from .base import Skill, SkillLoader, skill_catalog_text, skill_tools
|
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 dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Callable, Optional, Union
|
||||||
|
|
||||||
import aisuite as ai
|
import aisuite as ai
|
||||||
|
|
||||||
@@ -27,9 +27,17 @@ class Skill:
|
|||||||
|
|
||||||
class SkillLoader:
|
class SkillLoader:
|
||||||
def __init__(self, dirs: list[str | Path]) -> None:
|
def __init__(self, dirs: list[str | Path]) -> None:
|
||||||
|
self._dirs = [Path(d) for d in dirs]
|
||||||
self._skills: dict[str, Skill] = {}
|
self._skills: dict[str, Skill] = {}
|
||||||
for directory in dirs:
|
self.rescan()
|
||||||
self._discover(Path(directory))
|
|
||||||
|
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:
|
def _discover(self, directory: Path) -> None:
|
||||||
if not directory.is_dir():
|
if not directory.is_dir():
|
||||||
@@ -81,8 +89,12 @@ def _parse_skill(md: Path) -> Skill:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def skill_catalog_text(loader: SkillLoader) -> str:
|
def skill_catalog_text(
|
||||||
catalog = loader.catalog()
|
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:
|
if not catalog:
|
||||||
return ""
|
return ""
|
||||||
lines = [f"- {c['name']}: {c['description']}" for c in catalog]
|
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:
|
def load_skill(name: str) -> dict:
|
||||||
"""Load a skill's full instructions + resources path by name. Call this when a
|
"""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 from the catalog is relevant to the current task."""
|
||||||
skill = loader.get(name)
|
skill = loader.get(name)
|
||||||
if skill is None:
|
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 {
|
return {
|
||||||
"name": skill.name,
|
"name": skill.name,
|
||||||
"instructions": skill.instructions,
|
"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 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.
|
// 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("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.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.getByRole("button", { name: "Always allow this command" }).last()).toBeVisible();
|
||||||
await expect(page.getByText(/local action/)).toHaveCount(0);
|
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.
|
// 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
|
// monday is one-click ONLY (no manual fields); jira also has a manual token path
|
||||||
// (two-mode modal). Neither needs cloud sign-in.
|
// (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 },
|
{ 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
|
// 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).
|
// the app reads it back (which is what gates parking approvals to the Inbox vs an inline card).
|
||||||
const unattended: Record<string, boolean> = {};
|
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).
|
// Fresh cloud sign-in state per test (module state outlives a page).
|
||||||
Object.assign(CLOUD_STATE, {
|
Object.assign(CLOUD_STATE, {
|
||||||
@@ -585,7 +593,12 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
|||||||
const msg = JSON.parse(String(raw));
|
const msg = JSON.parse(String(raw));
|
||||||
if (msg.type === "user_message") {
|
if (msg.type === "user_message") {
|
||||||
hadTurn = true;
|
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)) {
|
if (/run a tool/i.test(msg.text)) {
|
||||||
pendingTool = "run_shell";
|
pendingTool = "run_shell";
|
||||||
send("tool_proposed", { name: "run_shell", arguments: { command: "ls" } });
|
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 });
|
send("assistant_delta", { text: msg.text });
|
||||||
// Echo the model the message carried — pins the model-per-message contract (the
|
// 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).
|
// 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
|
// `usage` mirrors the real engine's assistant_message sidecar (OPE-42): fixed
|
||||||
// counts per turn so the usage-chip specs can assert exact accumulation.
|
// counts per turn so the usage-chip specs can assert exact accumulation.
|
||||||
send("assistant_message", {
|
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: {
|
usage: {
|
||||||
model: msg.model || "anthropic:claude-opus-4-8",
|
model: msg.model || "anthropic:claude-opus-4-8",
|
||||||
input: 1_000,
|
input: 1_000,
|
||||||
@@ -836,6 +850,73 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
|||||||
return json(i >= 0 ? sessions[i] : PINNED_SESSION);
|
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/health")) return json(HEALTH);
|
||||||
if (p.endsWith("/v1/settings")) return json(SETTINGS);
|
if (p.endsWith("/v1/settings")) return json(SETTINGS);
|
||||||
if (p.endsWith("/v1/settings/context-bar") && m === "POST") {
|
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 [scheduledOpenId, setScheduledOpenId] = useState<string | null>(null);
|
||||||
const [gateCreate, setGateCreate] = useState(false);
|
const [gateCreate, setGateCreate] = useState(false);
|
||||||
// Which Settings section the full-page Settings surface opens on (§ Settings-as-page).
|
// Which Settings section the full-page Settings surface opens on (§ Settings-as-page).
|
||||||
const [settingsTab, setSettingsTab] = useState<"appearance" | "models" | "voice" | "personas">(
|
const [settingsTab, setSettingsTab] = useState<
|
||||||
"appearance",
|
"appearance" | "models" | "skills" | "voice" | "personas"
|
||||||
);
|
>("appearance");
|
||||||
const openSettings = (tab: "appearance" | "models" | "voice" | "personas" = "appearance") => {
|
const openSettings = (
|
||||||
|
tab: "appearance" | "models" | "skills" | "voice" | "personas" = "appearance",
|
||||||
|
) => {
|
||||||
setSettingsTab(tab);
|
setSettingsTab(tab);
|
||||||
setSurface("settings");
|
setSurface("settings");
|
||||||
};
|
};
|
||||||
@@ -613,11 +615,14 @@ export function App() {
|
|||||||
: [...p, { kind: "connector", source: src }];
|
: [...p, { kind: "connector", source: src }];
|
||||||
});
|
});
|
||||||
} else if (typeof d.input === "string" && d.input) {
|
} 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) => {
|
setItems((p) => {
|
||||||
const last = p[p.length - 1];
|
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
|
||||||
: [...p, { kind: "user", text: d.input as string, ts: Date.now() / 1000 }];
|
: [...p, { kind: "user", text: shown, ts: Date.now() / 1000 }];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -861,10 +866,13 @@ export function App() {
|
|||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [surface, sessionId, browserRefreshKey, markUnattended]);
|
}, [surface, sessionId, browserRefreshKey, markUnattended]);
|
||||||
|
|
||||||
const send = (text: string, attachments?: Attachment[]) => {
|
const send = (text: string, attachments?: Attachment[], skill?: string) => {
|
||||||
setItems((p) => [...p, { kind: "user", text, attachments, ts: Date.now() / 1000 }]);
|
// 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).
|
// 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
|
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
|
// Resolving a LIVE prompt also resolves its parked Inbox mirror server-side, but the polled
|
||||||
@@ -1349,6 +1357,17 @@ export function App() {
|
|||||||
key={settingsTab}
|
key={settingsTab}
|
||||||
initialTab={settingsTab}
|
initialTab={settingsTab}
|
||||||
onOpenPersona={(id) => openPersona(id, "settings")}
|
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" ? (
|
) : surface === "audit" ? (
|
||||||
<AuditView />
|
<AuditView />
|
||||||
@@ -1584,6 +1603,7 @@ export function App() {
|
|||||||
onInterrupt={interrupt}
|
onInterrupt={interrupt}
|
||||||
onModeChange={changeMode}
|
onModeChange={changeMode}
|
||||||
onModelChange={changeModel}
|
onModelChange={changeModel}
|
||||||
|
sessionId={sessionId}
|
||||||
workspace={needsWorkspace(agent) ? workspace || "" : undefined}
|
workspace={needsWorkspace(agent) ? workspace || "" : undefined}
|
||||||
unattended={unattended}
|
unattended={unattended}
|
||||||
onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined}
|
onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined}
|
||||||
|
|||||||
+141
-1
@@ -195,6 +195,8 @@ export interface ArtifactContent {
|
|||||||
content?: string;
|
content?: string;
|
||||||
data_url?: string;
|
data_url?: string;
|
||||||
truncated?: boolean;
|
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[]> {
|
export async function getArtifacts(sessionId: string): Promise<ArtifactInfo[]> {
|
||||||
@@ -1089,6 +1091,141 @@ export async function setSessionConnection(
|
|||||||
return res.json();
|
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 -------------------------------------------------------
|
// -- Inbox + Unattended -------------------------------------------------------
|
||||||
export interface InboxItem {
|
export interface InboxItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1846,12 +1983,15 @@ export class Session {
|
|||||||
* exactly what the user sees — immune to set_model races across reconnects (a new cowork
|
* 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
|
* 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). */
|
* 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({
|
this.send({
|
||||||
type: "user_message",
|
type: "user_message",
|
||||||
text,
|
text,
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
...(attachments?.length ? { attachments } : {}),
|
...(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 > 0
|
||||||
? `${roots.length} folder${roots.length === 1 ? "" : "s"}`
|
? `${roots.length} folder${roots.length === 1 ? "" : "s"}`
|
||||||
: null;
|
: null;
|
||||||
const summary = folderPart ? `${sourcesPart} · ${folderPart}` : sourcesPart;
|
const summary = [sourcesPart, folderPart].filter(Boolean).join(" · ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rail-section" ref={rootEl} data-testid="access-section">
|
<section className="rail-section" ref={rootEl} data-testid="access-section">
|
||||||
@@ -383,6 +383,14 @@ export function AccessSection({
|
|||||||
+ Add a source…
|
+ Add a source…
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{recommended.length > 0 && (
|
{recommended.length > 0 && (
|
||||||
@@ -456,13 +464,6 @@ export function AccessSection({
|
|||||||
)}
|
)}
|
||||||
{rootsError && <div className="roots-err">{rootsError}</div>}
|
{rootsError && <div className="roots-err">{rootsError}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
className="text-[12px] text-accent font-medium hover:underline text-left"
|
|
||||||
onClick={() => onOpenIntegrations?.()}
|
|
||||||
>
|
|
||||||
Manage all connectors (global) →
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ describe("ApprovalCard — §35 shapes", () => {
|
|||||||
expect(onApprove).toHaveBeenCalledWith("once");
|
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(
|
render(
|
||||||
<ApprovalCard
|
<ApprovalCard
|
||||||
item={sendApproval({
|
item={sendApproval({
|
||||||
@@ -121,7 +121,7 @@ describe("ApprovalCard — §35 shapes", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText(/Send a file to/).textContent).toContain("C9");
|
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(/report\.pdf/)).toBeTruthy();
|
||||||
expect(screen.getByText(/here you go/)).toBeTruthy();
|
expect(screen.getByText(/here you go/)).toBeTruthy();
|
||||||
expect(screen.getByText("Allow once")).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(/Run a command — fetch semiconductor stock data/)).toBeTruthy();
|
||||||
expect(screen.getByText(/python3 fetch\.py/)).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();
|
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.getByText("fetch_data.py")).toBeTruthy();
|
||||||
expect(screen.queryByText("Run `send_message`?")).toBeNull();
|
expect(screen.queryByText("Run `send_message`?")).toBeNull();
|
||||||
expect(screen.getByText(/import json/)).toBeTruthy();
|
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).
|
// §35 labels; resolution vocabulary unchanged (works on every approver path).
|
||||||
fireEvent.click(screen.getByText("Allow once"));
|
fireEvent.click(screen.getByText("Allow once"));
|
||||||
expect(onResolve).toHaveBeenCalledWith("i1", "allow");
|
expect(onResolve).toHaveBeenCalledWith("i1", "allow");
|
||||||
// Old rows without tool data keep the legacy treatment (covered above).
|
// 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" }>;
|
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
|
// A `permissions` proposal on the create_scheduled_task consent card (§25): reads are
|
||||||
// disclosure lines, writes are the standing grants the approval mints.
|
// disclosure lines, writes are the standing grants the approval mints.
|
||||||
interface PermissionLine {
|
interface PermissionLine {
|
||||||
@@ -65,14 +102,17 @@ export function scopeNote(
|
|||||||
args: any,
|
args: any,
|
||||||
category?: string,
|
category?: string,
|
||||||
): { text: string; external: boolean } {
|
): { 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 (category === "connector") return { text: "acts on a connected service", external: true };
|
||||||
if (EXTERNAL.has(name)) {
|
if (EXTERNAL.has(name)) {
|
||||||
const platform = String(args?.target ?? "").split(":")[0];
|
const platform = String(args?.target ?? "").split(":")[0];
|
||||||
const names: Record<string, string> = { slack: "Slack", telegram: "Telegram" };
|
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;
|
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
|
// The proposed content/command, straight from the tool call's ARGS — the file/action
|
||||||
@@ -125,11 +165,13 @@ function Buttons({
|
|||||||
onApprove,
|
onApprove,
|
||||||
runTask,
|
runTask,
|
||||||
primaryLabel,
|
primaryLabel,
|
||||||
|
denyLabel = "Deny",
|
||||||
}: {
|
}: {
|
||||||
item: ApprovalItem;
|
item: ApprovalItem;
|
||||||
onApprove: (decision: ApprovalDecision) => void;
|
onApprove: (decision: ApprovalDecision) => void;
|
||||||
runTask?: { id: string; title: string } | null;
|
runTask?: { id: string; title: string } | null;
|
||||||
primaryLabel: string;
|
primaryLabel: string;
|
||||||
|
denyLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const connector = item.category === "connector";
|
const connector = item.category === "connector";
|
||||||
const offerStanding = !!(runTask && item.standingTarget);
|
const offerStanding = !!(runTask && item.standingTarget);
|
||||||
@@ -152,7 +194,9 @@ function Buttons({
|
|||||||
exactly the scope distinction §25 exists to draw. Same rule for run_shell:
|
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
|
the command-scoped button below is the specific (safer) grant, so the
|
||||||
tool-wide one stays out of the card. */}
|
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
|
<button
|
||||||
className="btn"
|
className="btn"
|
||||||
title={`Always allow ${TOOL_VERBS[item.name]?.toLowerCase() || item.name} for this session`}
|
title={`Always allow ${TOOL_VERBS[item.name]?.toLowerCase() || item.name} for this session`}
|
||||||
@@ -168,7 +212,7 @@ function Buttons({
|
|||||||
)}
|
)}
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<button className="btn quiet-deny" onClick={() => onApprove("deny")}>
|
<button className="btn quiet-deny" onClick={() => onApprove("deny")}>
|
||||||
Deny
|
{denyLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -252,6 +296,8 @@ export function ApprovalCard({
|
|||||||
{item.name === "send_message" && item.args?.text && (
|
{item.name === "send_message" && item.args?.text && (
|
||||||
<MessagePreview text={String(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 && (
|
{grants.length > 0 && (
|
||||||
<div className="approval-grants" data-testid="approval-grants">
|
<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. */}
|
{/* Long-tail tools: no bespoke preview — fall back to the compact args line. */}
|
||||||
{!FILE_WRITES.has(item.name) &&
|
{!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 &&
|
!grants.length &&
|
||||||
shortArgs(item.args) && <div className="approval-rest">{shortArgs(item.args)}</div>}
|
shortArgs(item.args) && <div className="approval-rest">{shortArgs(item.args)}</div>}
|
||||||
{reason && <div className="approval-reason">{reason}</div>}
|
{reason && <div className="approval-reason">{reason}</div>}
|
||||||
@@ -280,7 +326,13 @@ export function ApprovalCard({
|
|||||||
{item.resolved ? (
|
{item.resolved ? (
|
||||||
<div className="resolved">Approved: {item.resolved.replace("_", " ")}</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ export function AutomationQuickstart({
|
|||||||
<span className="block text-[13px] text-ink font-medium">
|
<span className="block text-[13px] text-ink font-medium">
|
||||||
One sign-in unlocks every one-click connection
|
One sign-in unlocks every one-click connection
|
||||||
</span>
|
</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">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
{signinPhase ? (
|
{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 { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import type { Attachment, SessionUsage } from "../types";
|
import type { Attachment, SessionUsage } from "../types";
|
||||||
import { isPdfFile, readFile } from "../attach";
|
import { isPdfFile, readFile } from "../attach";
|
||||||
import { getSettings, inspectPdf } from "../api";
|
import { getSettings, inspectPdf, sessionSkills, type SessionSkillRow } from "../api";
|
||||||
import { formatTokens, totalTokens } from "../usage";
|
import { formatTokens, totalTokens } from "../usage";
|
||||||
import { Dropdown, type Option } from "./Dropdown";
|
import { Dropdown, type Option } from "./Dropdown";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
@@ -59,7 +59,10 @@ interface Props {
|
|||||||
modelReady?: boolean;
|
modelReady?: boolean;
|
||||||
onConnectModel?: () => void;
|
onConnectModel?: () => void;
|
||||||
onConfigureVoiceInput?: () => 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;
|
onInterrupt: () => void;
|
||||||
onModeChange: (mode: string) => void;
|
onModeChange: (mode: string) => void;
|
||||||
onModelChange: (model: string) => void;
|
onModelChange: (model: string) => void;
|
||||||
@@ -91,6 +94,46 @@ interface Props {
|
|||||||
export function Composer(props: Props) {
|
export function Composer(props: Props) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
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 [dragging, setDragging] = useState(false);
|
||||||
const [attachMenuOpen, setAttachMenuOpen] = useState(false);
|
const [attachMenuOpen, setAttachMenuOpen] = useState(false);
|
||||||
const [dictation, setDictation] = useState<DictationStatus | null>(null);
|
const [dictation, setDictation] = useState<DictationStatus | null>(null);
|
||||||
@@ -119,6 +162,17 @@ export function Composer(props: Props) {
|
|||||||
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
|
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
|
||||||
}, [text]);
|
}, [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
|
// 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
|
// 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.
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [props.prefill?.nonce]);
|
}, [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
|
// 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.
|
// and never turns on the browser microphone or ships audio anywhere.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -264,19 +310,54 @@ export function Composer(props: Props) {
|
|||||||
const needsModel = props.modelReady === false;
|
const needsModel = props.modelReady === false;
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
const t = text.trim();
|
// While the "/" popup is open the draft is a query, not a message — never send it.
|
||||||
if ((!t && attachments.length === 0) || props.running || dictation?.recording || dictationBusy) return;
|
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.
|
// No model connected: keep the draft (don't drop it) and send the user to setup instead.
|
||||||
if (needsModel) {
|
if (needsModel) {
|
||||||
props.onConnectModel?.();
|
props.onConnectModel?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
props.onSend(t, attachments);
|
props.onSend(t, attachments, skill);
|
||||||
setText("");
|
setText("");
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
|
setPendingSkill(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onKey = (e: React.KeyboardEvent) => {
|
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) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submit();
|
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
|
// 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.
|
// 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 (
|
return (
|
||||||
<div className="composer-wrap px-6 pb-5 pt-4">
|
<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);
|
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
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
className="w-full block px-3.5 pt-3.5 pb-1.5 text-[14.5px]"
|
className="w-full block px-3.5 pt-3.5 pb-1.5 text-[14.5px]"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type IconName =
|
|||||||
| "signOut"
|
| "signOut"
|
||||||
| "chat"
|
| "chat"
|
||||||
| "diamond"
|
| "diamond"
|
||||||
|
| "book"
|
||||||
| "search"
|
| "search"
|
||||||
| "folder"
|
| "folder"
|
||||||
| "folderPlus"
|
| "folderPlus"
|
||||||
@@ -66,6 +67,18 @@ export function Icon({
|
|||||||
};
|
};
|
||||||
|
|
||||||
switch (name) {
|
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":
|
case "sparkle":
|
||||||
// Filled 4-point twinkle — crisp at small sizes.
|
// Filled 4-point twinkle — crisp at small sizes.
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import type { InboxItem } from "../api";
|
import type { InboxItem } from "../api";
|
||||||
import { humanizeApprovalTitle } from "../humanize";
|
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
|
// 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.
|
// (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>
|
<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} />
|
<PreviewBlock text={item.data.arguments.content} />
|
||||||
) : item.kind === "approval" && item.data?.tool && typeof item.data.arguments?.command === "string" ? (
|
) : item.kind === "approval" && item.data?.tool && typeof item.data.arguments?.command === "string" ? (
|
||||||
<PreviewBlock text={item.data.arguments.command} />
|
<PreviewBlock text={item.data.arguments.command} />
|
||||||
@@ -102,7 +111,7 @@ export function InboxItemCard({
|
|||||||
className={item.data?.tool ? BTN_ACCENT : BTN_PRIMARY}
|
className={item.data?.tool ? BTN_ACCENT : BTN_PRIMARY}
|
||||||
onClick={() => onResolve(item.id, "allow")}
|
onClick={() => onResolve(item.id, "allow")}
|
||||||
>
|
>
|
||||||
{item.data?.tool ? "Allow once" : "Approve"}
|
{item.data?.tool ? approvalActionLabels(item.data.tool).allow : "Approve"}
|
||||||
</button>
|
</button>
|
||||||
{/* Task-persistent standing grant (§25) — present only when the approval was
|
{/* 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.
|
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}
|
className={item.data?.tool ? BTN_QUIET : BTN_BORDERED}
|
||||||
onClick={() => onResolve(item.id, "deny")}
|
onClick={() => onResolve(item.id, "deny")}
|
||||||
>
|
>
|
||||||
Deny
|
{item.data?.tool ? approvalActionLabels(item.data.tool).deny : "Deny"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : item.kind === "question" ? (
|
) : 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>
|
<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">
|
<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
|
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>
|
</p>
|
||||||
|
|
||||||
{!ps.sel ? (
|
{!ps.sel ? (
|
||||||
@@ -239,7 +239,7 @@ export function Onboarding({ onDone }: { onDone: (next?: "work" | "gallery" | "a
|
|||||||
Sign in for one-click connections
|
Sign in for one-click connections
|
||||||
</span>
|
</span>
|
||||||
OpenWorker handles the OAuth for 20+ tools — no dev consoles, no pasted keys.
|
OpenWorker handles the OAuth for 20+ tools — no dev consoles, no pasted keys.
|
||||||
Tokens stay on this Mac.
|
Tokens stay on this computer.
|
||||||
</span>
|
</span>
|
||||||
{signinPhase ? (
|
{signinPhase ? (
|
||||||
<span className="inline-flex items-center gap-2 text-[12.5px] text-muted shrink-0">
|
<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>
|
</div>
|
||||||
<p className="text-[11px] text-faint mt-3">
|
<p className="text-[11px] text-faint mt-3">
|
||||||
30+ more tools on the Connectors page — add or remove anytime. Tokens stay on
|
30+ more tools on the Connectors page — add or remove anytime. Tokens stay on
|
||||||
this Mac.
|
this computer.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -159,6 +159,15 @@ export function RightRail({
|
|||||||
content={content}
|
content={content}
|
||||||
onReload={reloadSelected}
|
onReload={reloadSelected}
|
||||||
onBack={() => setSelected(null)}
|
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,
|
content,
|
||||||
onReload,
|
onReload,
|
||||||
onBack,
|
onBack,
|
||||||
|
onOpenEntry,
|
||||||
}: {
|
}: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
artifact: ArtifactInfo;
|
artifact: ArtifactInfo;
|
||||||
content: ArtifactContent | null;
|
content: ArtifactContent | null;
|
||||||
onReload: () => Promise<void>;
|
onReload: () => Promise<void>;
|
||||||
onBack: () => 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 [reloadKey, setReloadKey] = useState(0);
|
||||||
const isHtml = content?.kind === "html" && !content.error;
|
const isHtml = content?.kind === "html" && !content.error;
|
||||||
@@ -381,6 +393,22 @@ function ArtifactViewer({
|
|||||||
<CsvTable text={content.content || ""} />
|
<CsvTable text={content.content || ""} />
|
||||||
) : content.kind === "sheet" ? (
|
) : content.kind === "sheet" ? (
|
||||||
<SheetViewer dataUrl={content.data_url || ""} />
|
<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" ? (
|
) : content.kind === "office" ? (
|
||||||
<div className="artifact-open-prompt">
|
<div className="artifact-open-prompt">
|
||||||
<Icon name="panelOpen" size={28} />
|
<Icon name="panelOpen" size={28} />
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { PanelHead } from "./IntegrationsView";
|
|||||||
import { ModelsTab } from "./ManageTabs";
|
import { ModelsTab } from "./ManageTabs";
|
||||||
import { GalleryModal } from "./GalleryModal";
|
import { GalleryModal } from "./GalleryModal";
|
||||||
import { PersonasTab } from "./PersonasTab";
|
import { PersonasTab } from "./PersonasTab";
|
||||||
|
import { SkillsTab } from "./SkillsTab";
|
||||||
import { showPersonas } from "../flags";
|
import { showPersonas } from "../flags";
|
||||||
|
|
||||||
// Settings, restructured (Option 2) into a full-page surface that mirrors IntegrationsView's shell:
|
// 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).
|
// 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
|
// "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.
|
// 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 CARD = "rounded-xl2 border border-line bg-panel";
|
||||||
const FIELD_LABEL = "text-[12.5px] font-medium text-ink";
|
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 =
|
const BTN_BORDERED =
|
||||||
"text-[12.5px] px-3 py-2 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
|
"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: "appearance", label: "General", icon: "sliders" },
|
||||||
{ key: "models", label: "Models", icon: "code" },
|
{ key: "models", label: "Models", icon: "code" },
|
||||||
|
{ key: "skills", label: "Skills", icon: "book" },
|
||||||
{ key: "voice", label: "Voice input", icon: "mic" },
|
{ key: "voice", label: "Voice input", icon: "mic" },
|
||||||
{ key: "personas", label: "Personas", icon: "sparkle" },
|
{ key: "personas", label: "Personas", icon: "sparkle" },
|
||||||
];
|
];
|
||||||
@@ -71,9 +73,13 @@ const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" |
|
|||||||
export function SettingsView({
|
export function SettingsView({
|
||||||
initialTab,
|
initialTab,
|
||||||
onOpenPersona,
|
onOpenPersona,
|
||||||
|
onCreateSkill,
|
||||||
}: {
|
}: {
|
||||||
initialTab?: SetTab;
|
initialTab?: SetTab;
|
||||||
onOpenPersona?: (id: string) => void;
|
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
|
// 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
|
// deep-link to it (openSettings("personas") callers) so the page never opens on a
|
||||||
@@ -124,6 +130,8 @@ export function SettingsView({
|
|||||||
<CompactionCard />
|
<CompactionCard />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
) : tab === "skills" ? (
|
||||||
|
<SkillsTab onCreateSkill={onCreateSkill} />
|
||||||
) : tab === "voice" ? (
|
) : tab === "voice" ? (
|
||||||
<VoiceInputSection />
|
<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")}>
|
<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" /> : "●"}
|
{running ? <span className="spinner" data-testid="step-running" /> : "●"}
|
||||||
</span>
|
</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)}
|
{approval && approvalChip(approval.resolved)}
|
||||||
{!!tool.standingRule && (
|
{!!tool.standingRule && (
|
||||||
<span
|
<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":
|
case "explore":
|
||||||
return { pre: "Sent a sub-agent to explore — ", obj: `“${trunc(String(a.task ?? a.prompt ?? ""), 60)}”` };
|
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":
|
case "ask_user":
|
||||||
return { pre: "Asked you a question" };
|
return { pre: "Asked you a question" };
|
||||||
case "propose_plan":
|
case "propose_plan":
|
||||||
@@ -131,6 +135,11 @@ export function humanizeApprovalTitle(name: string, args: any): HumanLine {
|
|||||||
return a.title
|
return a.title
|
||||||
? { pre: "Create the automation ", obj: `“${trunc(String(a.title), 60)}”` }
|
? { pre: "Create the automation ", obj: `“${trunc(String(a.title), 60)}”` }
|
||||||
: { pre: "Create an automation" };
|
: { 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:
|
default:
|
||||||
return { pre: `Use ${name}` };
|
return { pre: `Use ${name}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const user = userItemFromContent(m.content);
|
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.
|
// `ts` (unix seconds) is the server's canonical-message stamp; older sessions have none.
|
||||||
if (typeof m.ts === "number") user.ts = m.ts;
|
if (typeof m.ts === "number") user.ts = m.ts;
|
||||||
if (user.text || user.attachments?.length) items.push(user);
|
if (user.text || user.attachments?.length) items.push(user);
|
||||||
|
|||||||
@@ -499,7 +499,7 @@ export function ProviderForm({
|
|||||||
)}
|
)}
|
||||||
{info && !info.needs_key && (
|
{info && !info.needs_key && (
|
||||||
<p className="text-[11.5px] text-faint mt-2">
|
<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
|
<button
|
||||||
className="text-muted underline decoration-line underline-offset-2 hover:text-ink"
|
className="text-muted underline decoration-line underline-offset-2 hover:text-ink"
|
||||||
onClick={() => openExternal("https://ollama.com/download")}
|
onClick={() => openExternal("https://ollama.com/download")}
|
||||||
|
|||||||
@@ -397,6 +397,9 @@ body {
|
|||||||
margin-top: 11px; font-family: var(--mono); font-size: 12px; color: var(--muted);
|
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;
|
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;
|
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 {
|
.approval-prev-more {
|
||||||
display: block; margin-top: 4px; font: inherit; font-family: -apple-system, sans-serif;
|
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. */
|
fussy on a pill); small + high keeps the wordmark the star. */
|
||||||
vertical-align: 3px;
|
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"]
|
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):
|
def test_artifact_read_rejects_path_escape(tmp_path):
|
||||||
client = _client(tmp_path, [])
|
client = _client(tmp_path, [])
|
||||||
escaped = client.get(
|
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