mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-14 16:30:30 +00:00
Merge branch 'main' into feature/memory
This commit is contained in:
@@ -43,11 +43,13 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# No Intel macOS target: macos-13 (the last Intel runner image) is deprecated and
|
||||
# its queue waits run to hours, which blocks the release job. Intel Macs are
|
||||
# 2020-and-earlier hardware — revisit only if beta users actually ask.
|
||||
- os: macos-latest # Apple Silicon
|
||||
slug: macos-arm64
|
||||
# Intel macOS: macos-13 retired in Dec 2025; macos-15-intel replaced it and is
|
||||
# the LAST x86_64 image Actions will offer (available until Aug 2027). Builds
|
||||
# natively — the sidecar is a PyInstaller freeze, which cannot cross-compile.
|
||||
- os: macos-15-intel
|
||||
slug: macos-x64
|
||||
- os: windows-latest
|
||||
slug: windows
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -6,3 +6,6 @@ __pycache__/
|
||||
build/
|
||||
dist/
|
||||
.coverage
|
||||
|
||||
# Local secrets (live-smoke BYO keys) — never committed
|
||||
.env
|
||||
|
||||
+77
-6
@@ -7,7 +7,7 @@ the skill catalog (progressive disclosure) + load_skill into a TurnEngine.
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from .agents import Agent, AgentContext, code_agent
|
||||
from .automation import scheduling_tools
|
||||
@@ -36,7 +36,7 @@ from .roots import RootDir, normalize_roots, render_context
|
||||
from .providers import ProviderClient, ProviderRouter
|
||||
from .overrides import RiskOverrideStore
|
||||
from .secrets import SecretStore, state_dir
|
||||
from .skills import SkillLoader, skill_catalog_text, skill_tools
|
||||
from .skills import SkillLoader, save_skill_tool, skill_catalog_text, skill_tools
|
||||
from .tools import ToolRegistry
|
||||
from .tools.ask import ask_user_tool
|
||||
from .tools.directories import request_directory_tool
|
||||
@@ -134,6 +134,38 @@ def _enabled_connector_tools(secrets: SecretStore) -> tuple[set[str], set[str]]:
|
||||
return enabled_connectors, enabled_tools
|
||||
|
||||
|
||||
def _loaded_skill_names(messages: list[dict[str, Any]]) -> set[str]:
|
||||
"""Skills whose instructions successfully entered THIS conversation (a load_skill call
|
||||
with a non-error result). Drives the disable countermand: a menu quietly shrinking is
|
||||
passive, but instructions already in history keep steering the model unless it is
|
||||
explicitly asked to stop."""
|
||||
import json as _json
|
||||
|
||||
results: dict[str, str] = {}
|
||||
for m in messages:
|
||||
if m.get("role") == "tool" and m.get("tool_call_id"):
|
||||
content = m.get("content")
|
||||
results[m["tool_call_id"]] = (
|
||||
content if isinstance(content, str) else _json.dumps(content)
|
||||
)
|
||||
loaded: set[str] = set()
|
||||
for m in messages:
|
||||
if m.get("role") != "assistant" or not m.get("tool_calls"):
|
||||
continue
|
||||
for tc in m["tool_calls"]:
|
||||
fn = tc.get("function") or {}
|
||||
if fn.get("name") != "load_skill":
|
||||
continue
|
||||
try:
|
||||
name = str(_json.loads(fn.get("arguments") or "{}").get("name", ""))
|
||||
except Exception:
|
||||
continue
|
||||
result = results.get(tc.get("id", ""), "")
|
||||
if name and '"instructions"' in result:
|
||||
loaded.add(name)
|
||||
return loaded
|
||||
|
||||
|
||||
def _skill_dirs(workspace: Optional[Path]) -> list[Path]:
|
||||
dirs = [state_dir() / "skills"]
|
||||
if workspace is not None:
|
||||
@@ -183,6 +215,8 @@ def build_engine(
|
||||
channel_buffer: Optional[Any] = None,
|
||||
routing_targets: Optional[list[str]] = None,
|
||||
connector_filter: Optional[set[str]] = None,
|
||||
# A set (static snapshot) or a zero-arg callable (live, re-evaluated per load_skill).
|
||||
skill_filter: Optional[set[str] | Callable[[], set[str]]] = None,
|
||||
) -> TurnEngine:
|
||||
ws = Path(workspace).expanduser().resolve() if workspace else None
|
||||
if agent.needs_workspace and ws is None:
|
||||
@@ -341,10 +375,22 @@ def build_engine(
|
||||
instructions = f"{instructions}\n\n{block}"
|
||||
|
||||
skill_loader = SkillLoader(_skill_dirs(ws))
|
||||
registry.register_all(skill_tools(skill_loader))
|
||||
catalog = skill_catalog_text(skill_loader)
|
||||
if catalog:
|
||||
instructions = f"{instructions}\n\n{catalog}"
|
||||
# Per-session effective menu (SKILLS-SPEC §3). The manager passes a CALLABLE so
|
||||
# load_skill consults the LIVE state per call (a Settings disable applies to running
|
||||
# sessions; a skill created after this build is still loadable). The catalog itself
|
||||
# is injected per turn via context_provider (below), NOT here — so the menu the model
|
||||
# sees is also live: skill changes apply from the next message, no new session needed.
|
||||
# Default None preserves CLI / direct callers.
|
||||
registry.register_all(skill_tools(skill_loader, allowed=skill_filter))
|
||||
# The worker-authors door (SKILLS-SPEC §5.2): save_skill proposes installing a finished
|
||||
# skill; requires_approval routes it through the standard approval card, so the review-
|
||||
# before-save rule holds without any bespoke plumbing. Bundled files may only come from
|
||||
# this session's roots.
|
||||
registry.register(
|
||||
save_skill_tool(
|
||||
allowed_dirs=[r.path for r in (root_list or [])] or ([ws] if ws else [])
|
||||
)
|
||||
)
|
||||
|
||||
# User-local risk overrides (mainly to relax MCP's conservative default). Empty store →
|
||||
# no-op; never written by persona loading (the no-self-grant rule).
|
||||
@@ -378,6 +424,10 @@ def build_engine(
|
||||
else None
|
||||
)
|
||||
|
||||
# Late-bound engine ref: the closure needs the conversation history (for the disable
|
||||
# countermand) but the engine is constructed after the closure. Filled below.
|
||||
_engine_box: list = []
|
||||
|
||||
def context_provider() -> str:
|
||||
parts = []
|
||||
if permissions.mode is Mode.PLAN:
|
||||
@@ -393,6 +443,26 @@ def build_engine(
|
||||
ctx = roots_context()
|
||||
if ctx:
|
||||
parts.append(ctx)
|
||||
# Live skill menu (SKILLS-SPEC §4.1): recomputed every turn like the roots list, so
|
||||
# a skill installed/enabled/disabled mid-session applies from the NEXT MESSAGE —
|
||||
# no new session, no lost context.
|
||||
skill_loader.rescan()
|
||||
allowed = skill_filter() if callable(skill_filter) else skill_filter
|
||||
skills_ctx = skill_catalog_text(skill_loader, allowed=allowed)
|
||||
if skills_ctx:
|
||||
parts.append(skills_ctx)
|
||||
# Disable countermand (§3): instructions already loaded into this conversation keep
|
||||
# steering the model even after the skill is turned off/deleted — history can't be
|
||||
# un-read. So a loaded-but-no-longer-available skill gets an explicit stop note,
|
||||
# recomputed fresh each turn (re-enable → the note disappears; never persisted).
|
||||
eng = _engine_box[0] if _engine_box else None
|
||||
if eng is not None:
|
||||
available = set(skill_loader.names()) if allowed is None else set(allowed)
|
||||
for name in sorted(_loaded_skill_names(eng.messages) - available):
|
||||
parts.append(
|
||||
f'Note: the skill "{name}" has been disabled by the user — stop '
|
||||
"following its instructions from here on."
|
||||
)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
engine = TurnEngine(
|
||||
@@ -425,6 +495,7 @@ def build_engine(
|
||||
"workspace": str(ws) if ws else "",
|
||||
}
|
||||
engine.skill_loader = skill_loader # type: ignore[attr-defined]
|
||||
_engine_box.append(engine) # late-bind for the countermand (see context_provider)
|
||||
return engine
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
"""Auto-compaction of long session histories (OPE-27).
|
||||
|
||||
When the outbound history approaches the model's context limit, the older portion of the
|
||||
*outbound* view is replaced with (a) an LLM-written structured summary and (b) mechanically
|
||||
extracted state — the recent turns and all user messages survive. The persisted transcript
|
||||
is never modified; only what is sent to the model. Full design: ocw-context
|
||||
docs/auto-compaction-spec.md (approved 2026-07-28).
|
||||
|
||||
This module is pure functions + one dataclass; the engine owns *when* (its run loop) and
|
||||
*with what* (its provider/model), both injected here. That split keeps the engine.py
|
||||
footprint to a few lines and makes every policy testable without a provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
# Trigger: min(threshold_pct × context_window, cap_tokens). The cap exists so 1M-context
|
||||
# models compact early — quality and latency degrade well before the nominal limit.
|
||||
DEFAULT_THRESHOLD_PCT = 0.8
|
||||
DEFAULT_CAP_TOKENS = 250_000
|
||||
# Models without a verified context_window entry in the matrix.
|
||||
DEFAULT_CONTEXT_WINDOW = 128_000
|
||||
# The newest slice kept verbatim, as a fraction of the trigger (a token budget, not a
|
||||
# turn count — one huge tool loop shouldn't starve the working set).
|
||||
KEEP_RECENT_FRACTION = 0.25
|
||||
# The summarizer call itself: tools off, modest ceiling.
|
||||
SUMMARY_MAX_TOKENS = 3_000
|
||||
# Per-message clip when rendering the span for the summarizer; tool results are the
|
||||
# first casualty (huge and mostly stale — a file read 40 turns ago is better re-read).
|
||||
_SPAN_TOOL_RESULT_CLIP = 400
|
||||
_SPAN_BUDGET_CHARS = 400_000
|
||||
# User messages preserved mechanically in the compacted block ("trimmed of pasted bulk").
|
||||
# The list is capped to the newest N across repeated compactions — otherwise it appends
|
||||
# forever and the block slowly reclaims the window it freed. Dropped ones stay counted
|
||||
# (their intent lives in the summary, which is asked to list user messages too).
|
||||
_USER_MESSAGE_CLIP = 600
|
||||
_USER_MESSAGES_MAX = 40
|
||||
_TRIM_FRACTION = 0.10
|
||||
|
||||
|
||||
# -- token math ---------------------------------------------------------------
|
||||
|
||||
|
||||
def estimate_tokens(messages: list[dict[str, Any]]) -> int:
|
||||
"""chars/4 over the serialized messages — the fallback signal for providers that
|
||||
never report usage (documented in the metering code)."""
|
||||
total = 0
|
||||
for msg in messages:
|
||||
try:
|
||||
total += len(json.dumps(msg, default=str))
|
||||
except (TypeError, ValueError):
|
||||
total += len(str(msg))
|
||||
return total // 4
|
||||
|
||||
|
||||
def trigger_tokens(
|
||||
context_window: Optional[int],
|
||||
*,
|
||||
threshold_pct: float = DEFAULT_THRESHOLD_PCT,
|
||||
cap_tokens: int = DEFAULT_CAP_TOKENS,
|
||||
) -> int:
|
||||
window = context_window or DEFAULT_CONTEXT_WINDOW
|
||||
return min(int(threshold_pct * window), int(cap_tokens))
|
||||
|
||||
|
||||
def should_compact(
|
||||
signal: int,
|
||||
context_window: Optional[int],
|
||||
*,
|
||||
threshold_pct: float = DEFAULT_THRESHOLD_PCT,
|
||||
cap_tokens: int = DEFAULT_CAP_TOKENS,
|
||||
) -> bool:
|
||||
return signal >= trigger_tokens(
|
||||
context_window, threshold_pct=threshold_pct, cap_tokens=cap_tokens
|
||||
)
|
||||
|
||||
|
||||
# -- state --------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompactionState:
|
||||
"""One compaction point. `boundary_index` is an index into the CANONICAL message list:
|
||||
messages before it are represented by the compacted block in the outbound view; messages
|
||||
from it on are sent verbatim. Persisted with the session so reloads keep the view."""
|
||||
|
||||
boundary_index: int
|
||||
summary_text: str
|
||||
working_state: str
|
||||
user_messages: list[str] = field(default_factory=list)
|
||||
# How many older user messages were dropped by the _USER_MESSAGES_MAX cap, across
|
||||
# all compactions of this session — keeps the block's "N earlier omitted" honest.
|
||||
user_messages_dropped: int = 0
|
||||
created_at: float = 0.0
|
||||
model_used: str = ""
|
||||
trimmed: bool = False # True when this state came from the no-summary trim fallback
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"boundary_index": self.boundary_index,
|
||||
"summary_text": self.summary_text,
|
||||
"working_state": self.working_state,
|
||||
"user_messages": list(self.user_messages),
|
||||
"user_messages_dropped": self.user_messages_dropped,
|
||||
"created_at": self.created_at,
|
||||
"model_used": self.model_used,
|
||||
"trimmed": self.trimmed,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: Any) -> Optional["CompactionState"]:
|
||||
if not isinstance(raw, dict) or "boundary_index" not in raw:
|
||||
return None
|
||||
return cls(
|
||||
boundary_index=int(raw.get("boundary_index", 0)),
|
||||
summary_text=str(raw.get("summary_text", "")),
|
||||
working_state=str(raw.get("working_state", "")),
|
||||
user_messages=[str(u) for u in raw.get("user_messages") or []],
|
||||
user_messages_dropped=int(raw.get("user_messages_dropped", 0)),
|
||||
created_at=float(raw.get("created_at", 0.0)),
|
||||
model_used=str(raw.get("model_used", "")),
|
||||
trimmed=bool(raw.get("trimmed", False)),
|
||||
)
|
||||
|
||||
|
||||
# -- boundary -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _turn_starts(messages: list[dict[str, Any]], *, start: int) -> tuple[list[int], list[int]]:
|
||||
"""Candidate boundary indexes past `start`: user-message indexes (turn starts,
|
||||
preferred) and assistant indexes (iteration starts — legal suffix heads; a `tool`
|
||||
message must never head the outbound view)."""
|
||||
users, assistants = [], []
|
||||
for i in range(start, len(messages)):
|
||||
role = messages[i].get("role")
|
||||
if role == "user":
|
||||
users.append(i)
|
||||
elif role == "assistant":
|
||||
assistants.append(i)
|
||||
return users, assistants
|
||||
|
||||
|
||||
def pick_boundary(messages: list[dict[str, Any]], *, keep_tokens: int) -> Optional[int]:
|
||||
"""The canonical index where the verbatim tail begins: the earliest turn start whose
|
||||
suffix fits the keep budget. Prefers user-message boundaries; falls back to iteration
|
||||
(assistant) boundaries when the newest turn alone exceeds the budget (a giant tool
|
||||
loop). None when there is nothing meaningful to summarize."""
|
||||
start = 1 if messages and messages[0].get("role") == "system" else 0
|
||||
users, assistants = _turn_starts(messages, start=start)
|
||||
|
||||
def _fit(candidates: list[int]) -> Optional[int]:
|
||||
for i in candidates: # earliest-first: keep as much verbatim as fits
|
||||
if estimate_tokens(messages[i:]) <= keep_tokens:
|
||||
return i
|
||||
return None
|
||||
|
||||
boundary = _fit(users)
|
||||
if boundary is None and users:
|
||||
# The newest user turn alone blows the budget — cut inside it at an iteration
|
||||
# boundary, keeping at least the most recent assistant step.
|
||||
inside = [i for i in assistants if i > users[-1]]
|
||||
boundary = _fit(inside)
|
||||
if boundary is None:
|
||||
boundary = inside[-1] if inside else users[-1]
|
||||
if boundary is None:
|
||||
boundary = _fit(assistants) or (assistants[-1] if assistants else None)
|
||||
# A boundary at (or before) the first real message summarizes nothing — skip.
|
||||
if boundary is None or boundary <= start:
|
||||
return None
|
||||
return boundary
|
||||
|
||||
|
||||
# -- mechanical extraction (no LLM — zero hallucination risk) -----------------
|
||||
|
||||
_WRITE_HINTS = ("write", "edit", "append", "save", "create", "patch")
|
||||
_ARTIFACT_HINTS = ("artifact", "publish", "deploy")
|
||||
|
||||
|
||||
def _iter_tool_calls(span: list[dict[str, Any]]):
|
||||
"""(name, args, result_content) for every tool call in the span, in order."""
|
||||
results = {
|
||||
m.get("tool_call_id"): m.get("content")
|
||||
for m in span
|
||||
if m.get("role") == "tool"
|
||||
}
|
||||
for msg in span:
|
||||
if msg.get("role") != "assistant":
|
||||
continue
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
fn = tc.get("function") or {}
|
||||
try:
|
||||
args = json.loads(fn.get("arguments") or "{}")
|
||||
except (ValueError, TypeError):
|
||||
args = {}
|
||||
yield str(fn.get("name") or ""), args, results.get(tc.get("id"))
|
||||
|
||||
|
||||
def _result_status(result: Any) -> str:
|
||||
if not isinstance(result, str):
|
||||
return ""
|
||||
try:
|
||||
parsed = json.loads(result)
|
||||
except (ValueError, TypeError):
|
||||
return ""
|
||||
if not isinstance(parsed, dict):
|
||||
return ""
|
||||
if parsed.get("error"):
|
||||
return "error"
|
||||
if "exit_code" in parsed:
|
||||
code = parsed.get("exit_code")
|
||||
return "ok" if code in (0, "0") else f"exit {code}"
|
||||
return ""
|
||||
|
||||
|
||||
def extract_working_state(span: list[dict[str, Any]]) -> str:
|
||||
"""The mechanical block appended to the summary by CODE, from the span's tool-call
|
||||
records: files written, recent commands (+ exit status), artifacts, tools used."""
|
||||
files: list[str] = []
|
||||
commands: list[str] = []
|
||||
artifacts: list[str] = []
|
||||
tools: list[str] = []
|
||||
for name, args, result in _iter_tool_calls(span):
|
||||
if name and name not in tools:
|
||||
tools.append(name)
|
||||
lowered = name.lower()
|
||||
path = args.get("path") or args.get("file_path")
|
||||
if path and any(h in lowered for h in _WRITE_HINTS):
|
||||
files.append(str(path))
|
||||
if lowered == "run_shell" and args.get("command"):
|
||||
status = _result_status(result)
|
||||
line = " ".join(str(args["command"]).split())[:160]
|
||||
commands.append(f"{line}" + (f" [{status}]" if status else ""))
|
||||
if any(h in lowered for h in _ARTIFACT_HINTS):
|
||||
location = args.get("url") or args.get("path") or args.get("title")
|
||||
if location:
|
||||
artifacts.append(str(location))
|
||||
|
||||
def _dedupe_recent_first(items: list[str], limit: int) -> list[str]:
|
||||
seen: list[str] = []
|
||||
for item in reversed(items): # most recent first
|
||||
if item not in seen:
|
||||
seen.append(item)
|
||||
if len(seen) >= limit:
|
||||
break
|
||||
return seen
|
||||
|
||||
lines = ["## Working state (extracted mechanically from tool records)"]
|
||||
written = _dedupe_recent_first(files, 20)
|
||||
if written:
|
||||
lines.append("Files written/edited (most recent first):")
|
||||
lines += [f"- {p}" for p in written]
|
||||
recent_cmds = commands[-10:]
|
||||
if recent_cmds:
|
||||
lines.append("Recent shell commands:")
|
||||
lines += [f"- {c}" for c in recent_cmds]
|
||||
made = _dedupe_recent_first(artifacts, 10)
|
||||
if made:
|
||||
lines.append("Artifacts produced:")
|
||||
lines += [f"- {a}" for a in made]
|
||||
if tools:
|
||||
lines.append("Tools used in the summarized span: " + ", ".join(sorted(tools)))
|
||||
return "\n".join(lines) if len(lines) > 1 else ""
|
||||
|
||||
|
||||
def _text_of(content: Any) -> str:
|
||||
"""A message's text, whether plain or content-parts (images become a placeholder)."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for p in content:
|
||||
if isinstance(p, dict) and p.get("type") == "text":
|
||||
parts.append(str(p.get("text", "")))
|
||||
elif isinstance(p, dict) and p.get("type") == "image_url":
|
||||
parts.append("[image]")
|
||||
return "\n".join(parts)
|
||||
return "" if content is None else str(content)
|
||||
|
||||
|
||||
def extract_user_messages(
|
||||
span: list[dict[str, Any]], *, clip: int = _USER_MESSAGE_CLIP
|
||||
) -> list[str]:
|
||||
"""Every user message in the span, chronological, trimmed of pasted bulk. Preserved
|
||||
mechanically — the summarizer is also asked to list them, but user words are the
|
||||
ground truth of intent and must not depend on an LLM remembering to include them."""
|
||||
out: list[str] = []
|
||||
for msg in span:
|
||||
if msg.get("role") != "user":
|
||||
continue
|
||||
text = " ".join(_text_of(msg.get("content")).split())
|
||||
if not text:
|
||||
continue
|
||||
out.append(text[: clip - 1] + "…" if len(text) > clip else text)
|
||||
return out
|
||||
|
||||
|
||||
def _cap_user_messages(
|
||||
messages: list[str], *, prior_dropped: int, limit: int = _USER_MESSAGES_MAX
|
||||
) -> tuple[list[str], int]:
|
||||
"""Newest-`limit` slice plus the running total of everything ever dropped."""
|
||||
if len(messages) <= limit:
|
||||
return messages, prior_dropped
|
||||
return messages[-limit:], prior_dropped + (len(messages) - limit)
|
||||
|
||||
|
||||
# -- summarizer ---------------------------------------------------------------
|
||||
|
||||
SUMMARY_SYSTEM_PROMPT = """You are compacting an AI coworker's session history so the coworker can continue working in a smaller context. Write a structured summary of the conversation below. It is the coworker's ONLY memory of these turns, so preserve everything load-bearing.
|
||||
|
||||
Produce ALL of the following sections, in this order, each as a markdown heading:
|
||||
|
||||
1. **Primary request and intent** — what the user is trying to get done, in their terms, including standing constraints stated at any point (e.g. "never send without my approval"). Constraints outlive the turns they were stated in.
|
||||
2. **Key concepts and decisions** — domain facts, technical choices, and rationale established so far. Include the WHY, not just the what — a decision without its reason gets relitigated.
|
||||
3. **Artifacts and files** — every file/deliverable created, modified, or read that still matters: path, its role, and a short excerpt of load-bearing content only.
|
||||
4. **Errors and fixes** — problems hit and how they were resolved, including user corrections ("no, do it this way") — those are feedback with lasting force.
|
||||
5. **All user messages** — a chronological list of every user message (trimmed of pasted bulk). This is the intent audit-trail.
|
||||
6. **Pending tasks** — explicitly incomplete items, promised follow-ups, things the user said "later" about.
|
||||
7. **Current work** — precisely what was in progress at this point: which step, which file, what state.
|
||||
8. **Next step** — the immediate next action, justified by the user's request.
|
||||
|
||||
Rules:
|
||||
- Do NOT carry full file contents as truth. Note THAT a file was read/edited; the coworker re-reads if it needs the content again. Stale memory of a file is worse than no memory.
|
||||
- Be concrete: paths, names, commands, ids — not vague references.
|
||||
- Output only the summary sections, no preamble."""
|
||||
|
||||
CONTINUATION_CONTRACT = (
|
||||
"Continue where you left off: pick up the current work and next step exactly as "
|
||||
"described. Do not re-ask answered questions, do not recap, do not mention that the "
|
||||
"context was compacted. If you need the contents of a file noted above, re-read it."
|
||||
)
|
||||
|
||||
|
||||
def _render_span(span: list[dict[str, Any]], *, budget_chars: int = _SPAN_BUDGET_CHARS) -> str:
|
||||
"""The summarized span as compact text for the summarizer. Tool results are clipped
|
||||
hard (first casualty); if the whole render still exceeds the budget, oldest lines are
|
||||
dropped — the newest context is the most load-bearing."""
|
||||
lines: list[str] = []
|
||||
for msg in span:
|
||||
role = msg.get("role")
|
||||
if role == "system":
|
||||
continue
|
||||
if role == "notice":
|
||||
continue
|
||||
if role == "tool":
|
||||
text = _text_of(msg.get("content"))
|
||||
text = " ".join(text.split())
|
||||
if len(text) > _SPAN_TOOL_RESULT_CLIP:
|
||||
text = text[: _SPAN_TOOL_RESULT_CLIP - 1] + "…"
|
||||
lines.append(f"[tool result] {text}")
|
||||
continue
|
||||
text = _text_of(msg.get("content"))
|
||||
if role == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
fn = tc.get("function") or {}
|
||||
args = " ".join(str(fn.get("arguments", "")).split())
|
||||
if len(args) > 200:
|
||||
args = args[:199] + "…"
|
||||
lines.append(f"[assistant → {fn.get('name')}] {args}")
|
||||
if text:
|
||||
lines.append(f"[assistant] {text}")
|
||||
elif role == "user":
|
||||
lines.append(f"[user] {text}")
|
||||
rendered = "\n".join(lines)
|
||||
if len(rendered) > budget_chars:
|
||||
rendered = "(…oldest turns elided…)\n" + rendered[-budget_chars:]
|
||||
return rendered
|
||||
|
||||
|
||||
def summarizer_messages(
|
||||
span: list[dict[str, Any]], *, prior_summary: str = ""
|
||||
) -> list[dict[str, Any]]:
|
||||
"""The provider-ready messages for the summarizer call. On repeated compaction the
|
||||
previous summary is message zero of the new span — summarized along with the turns
|
||||
since."""
|
||||
body = _render_span(span)
|
||||
if prior_summary:
|
||||
body = (
|
||||
"[previous compaction summary — fold its still-relevant content into the new "
|
||||
"summary]\n" + prior_summary + "\n\n[conversation since]\n" + body
|
||||
)
|
||||
return [
|
||||
{"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": body},
|
||||
]
|
||||
|
||||
|
||||
def summarize_span(
|
||||
provider: Any,
|
||||
model: str,
|
||||
span: list[dict[str, Any]],
|
||||
*,
|
||||
prior_summary: str = "",
|
||||
max_tokens: int = SUMMARY_MAX_TOKENS,
|
||||
) -> str:
|
||||
"""One summarizer round-trip (blocking — the engine runs it off-loop). Tools are
|
||||
disabled; the Settings model override is just a different `model` id. Raises on
|
||||
provider failure or an empty summary — the caller owns the retry/trim policy."""
|
||||
turn = provider.complete(
|
||||
model=model,
|
||||
messages=summarizer_messages(span, prior_summary=prior_summary),
|
||||
tools=None,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
text = (getattr(turn, "text", None) or "").strip()
|
||||
if not text:
|
||||
raise RuntimeError("summarizer returned an empty summary")
|
||||
return text
|
||||
|
||||
|
||||
# -- building + applying a compaction -----------------------------------------
|
||||
|
||||
|
||||
def build_state(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
provider: Any,
|
||||
model: str,
|
||||
keep_tokens: int,
|
||||
prior: Optional[CompactionState] = None,
|
||||
) -> Optional[CompactionState]:
|
||||
"""Summarize everything older than the picked boundary into a new CompactionState.
|
||||
On repeated compaction the prior summary heads the new span. Returns None when there
|
||||
is nothing to compact; raises when the summarizer fails (caller applies policy)."""
|
||||
boundary = pick_boundary(messages, keep_tokens=keep_tokens)
|
||||
if boundary is None or (prior is not None and boundary <= prior.boundary_index):
|
||||
return None
|
||||
span_start = prior.boundary_index if prior is not None else 0
|
||||
span = messages[span_start:boundary]
|
||||
prior_users = list(prior.user_messages) if prior is not None else []
|
||||
summary = summarize_span(
|
||||
provider,
|
||||
model,
|
||||
span,
|
||||
prior_summary=prior.summary_text if prior is not None else "",
|
||||
)
|
||||
users, dropped = _cap_user_messages(
|
||||
prior_users + extract_user_messages(span),
|
||||
prior_dropped=prior.user_messages_dropped if prior is not None else 0,
|
||||
)
|
||||
return CompactionState(
|
||||
boundary_index=boundary,
|
||||
summary_text=summary,
|
||||
working_state=extract_working_state(span),
|
||||
user_messages=users,
|
||||
user_messages_dropped=dropped,
|
||||
created_at=time.time(),
|
||||
model_used=model,
|
||||
)
|
||||
|
||||
|
||||
def trim_state(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
prior: Optional[CompactionState] = None,
|
||||
fraction: float = _TRIM_FRACTION,
|
||||
) -> Optional[CompactionState]:
|
||||
"""The no-LLM fallback: advance the boundary past ~`fraction` of the outbound
|
||||
messages. No summary — but the mechanical block and the user-message list (never
|
||||
trimmed away, per spec) are free, so the model still gets deterministic state."""
|
||||
start = prior.boundary_index if prior is not None else 0
|
||||
remaining = len(messages) - start
|
||||
if remaining <= 2:
|
||||
return None
|
||||
step = max(1, int(remaining * fraction))
|
||||
target = start + step
|
||||
# Land on a legal suffix head at or after the target (never a tool message).
|
||||
boundary = None
|
||||
for i in range(target, len(messages)):
|
||||
if messages[i].get("role") in ("user", "assistant"):
|
||||
boundary = i
|
||||
break
|
||||
if boundary is None or boundary <= start or boundary >= len(messages):
|
||||
return None
|
||||
span = messages[start:boundary]
|
||||
prior_users = list(prior.user_messages) if prior is not None else []
|
||||
summary = (
|
||||
(prior.summary_text + "\n\n" if prior is not None and prior.summary_text else "")
|
||||
+ "(Older turns were trimmed to fit the context window; no summary is available "
|
||||
"for them. Re-read files and re-run commands if earlier results are needed.)"
|
||||
)
|
||||
users, dropped = _cap_user_messages(
|
||||
prior_users + extract_user_messages(span),
|
||||
prior_dropped=prior.user_messages_dropped if prior is not None else 0,
|
||||
)
|
||||
return CompactionState(
|
||||
boundary_index=boundary,
|
||||
summary_text=summary,
|
||||
working_state=extract_working_state(span),
|
||||
user_messages=users,
|
||||
user_messages_dropped=dropped,
|
||||
created_at=time.time(),
|
||||
model_used="",
|
||||
trimmed=True,
|
||||
)
|
||||
|
||||
|
||||
def compacted_block(state: CompactionState) -> str:
|
||||
"""The single outbound message standing in for everything before the boundary."""
|
||||
parts = [
|
||||
"<compacted-history>",
|
||||
"Earlier turns of this session were compacted. The summary below is your memory "
|
||||
"of them.",
|
||||
"",
|
||||
state.summary_text,
|
||||
]
|
||||
if state.working_state:
|
||||
parts += ["", state.working_state]
|
||||
if state.user_messages:
|
||||
parts += ["", "## User messages in the compacted span (verbatim, chronological)"]
|
||||
if state.user_messages_dropped:
|
||||
parts += [
|
||||
f"({state.user_messages_dropped} earlier user messages omitted — "
|
||||
"their intent is covered by the summary above)"
|
||||
]
|
||||
parts += [f"- {u}" for u in state.user_messages]
|
||||
parts += ["", CONTINUATION_CONTRACT, "</compacted-history>"]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def apply_to_outbound(
|
||||
messages: list[dict[str, Any]], state: Optional[CompactionState]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""The outbound view: [system?] + the compacted block (as a user message) + the
|
||||
verbatim tail. Canonical history is untouched; provider-private sidecars in the
|
||||
summarized span vanish with it (replay chains legally restart after a compaction
|
||||
point). No-op when state is absent or stale."""
|
||||
if state is None:
|
||||
return messages
|
||||
boundary = state.boundary_index
|
||||
if boundary <= 0 or boundary >= len(messages):
|
||||
return messages
|
||||
head: list[dict[str, Any]] = []
|
||||
if messages and messages[0].get("role") == "system":
|
||||
head.append(messages[0])
|
||||
head.append({"role": "user", "content": compacted_block(state)})
|
||||
return head + messages[boundary:]
|
||||
|
||||
|
||||
# -- overflow detection -------------------------------------------------------
|
||||
|
||||
_OVERFLOW_MARKERS = (
|
||||
"context_length_exceeded",
|
||||
"maximum context length",
|
||||
"context window",
|
||||
"prompt is too long",
|
||||
"input is too long",
|
||||
"too many tokens",
|
||||
"input length and `max_tokens` exceed",
|
||||
"exceeds the maximum number of tokens",
|
||||
)
|
||||
|
||||
|
||||
def is_context_overflow(exc: BaseException) -> bool:
|
||||
"""A raw context-overflow 400 from the main model (compaction mispredicted, e.g. the
|
||||
estimate path) — routed into the compaction policy instead of surfacing."""
|
||||
text = str(exc).lower()
|
||||
return any(marker in text for marker in _OVERFLOW_MARKERS)
|
||||
@@ -17,6 +17,8 @@ from typing import Any, Callable, Optional
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
from ..web.guard import check_url
|
||||
|
||||
|
||||
def _meta(
|
||||
name: str, *, approval: bool = False, capabilities: Optional[list[str]] = None
|
||||
@@ -332,6 +334,12 @@ def make_browser_automation_tools() -> list[Callable[..., Any]]:
|
||||
) -> dict[str, Any]:
|
||||
if not url.lower().startswith(("http://", "https://")):
|
||||
return {"error": "url must start with http:// or https://"}
|
||||
# Same address guard as web_fetch. This is approval gated, so it is defense in
|
||||
# depth, not the primary control. It checks the initial model supplied URL only;
|
||||
# redirects that the browser follows internally are not hop checked here.
|
||||
blocked = check_url(url)
|
||||
if blocked:
|
||||
return {"error": blocked}
|
||||
return _BROWSER.call(
|
||||
"open_url",
|
||||
lambda page: (
|
||||
|
||||
@@ -49,7 +49,7 @@ ABOUT: dict[str, str] = {
|
||||
"Multiple accounts connect side by side.",
|
||||
"monday": "Work with your monday.com boards — read items, summarize and "
|
||||
"aggregate board data, create items, and post updates. One-click sign-in "
|
||||
"runs entirely on this Mac against monday.com's own agent service; agents "
|
||||
"runs entirely on this computer against monday.com's own agent service; agents "
|
||||
"get a small curated set of its tools, never the full catalog.",
|
||||
"asana": "Keep up with your Asana work — search and read tasks and "
|
||||
"projects, create tasks, and comment. Connects with a personal access "
|
||||
|
||||
@@ -66,7 +66,7 @@ class ConnectorDescriptor:
|
||||
# doesn't carry (e.g. "calendar" must surface Outlook, not just Google Calendar).
|
||||
aliases: tuple = ()
|
||||
# Vendor-hosted MCP server URL → this connector is MCP-BACKED: one-click connect
|
||||
# runs the local MCP OAuth flow (DCR, tokens on this Mac — no broker), and the
|
||||
# runs the local MCP OAuth flow (DCR, tokens on this computer — no broker), and the
|
||||
# tool surface is the PINNED subset in tool_defs (names `mcp__<name>__<tool>`),
|
||||
# never the vendor's full catalog (drift can only shrink capability, not grow it).
|
||||
# A connector may carry BOTH mcp_url and manual fields (jira): the profile's
|
||||
@@ -704,7 +704,7 @@ DESCRIPTORS: list[ConnectorDescriptor] = [
|
||||
fields=[],
|
||||
instructions=[
|
||||
"One click connects via monday.com sign-in in your browser.",
|
||||
"Sign-in is fully local — tokens stay on this Mac.",
|
||||
"Sign-in is fully local — tokens stay on this computer.",
|
||||
],
|
||||
available=True,
|
||||
),
|
||||
|
||||
@@ -19,6 +19,7 @@ from urllib.parse import quote
|
||||
import aisuite as ai
|
||||
|
||||
from ..secrets import SecretStore
|
||||
from ..web.guard import get_checked
|
||||
from .browser_automation import make_browser_automation_tools
|
||||
from .email_tools import make_email_tools
|
||||
from .tool_defs import approval_for_tool, connector_for_tool
|
||||
@@ -305,15 +306,39 @@ def _gmail_is_hidden(
|
||||
|
||||
|
||||
def _request(
|
||||
method: str, url: str, *, headers=None, params=None, json=None, auth=None
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers=None,
|
||||
params=None,
|
||||
json=None,
|
||||
auth=None,
|
||||
check_addresses: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""HTTP for the connectors.
|
||||
|
||||
`check_addresses` is for URLs the *model* supplies (browser_read_url). It turns off
|
||||
automatic redirects and walks the chain through the address guard instead, so a public
|
||||
URL cannot 302 into loopback or the metadata endpoint. The vendor endpoints everything
|
||||
else in this module calls are hardcoded, so they skip the guard and its DNS lookup.
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
||||
resp = client.request(
|
||||
method, url, headers=headers, params=params, json=json, auth=auth
|
||||
)
|
||||
with httpx.Client(
|
||||
timeout=30.0, follow_redirects=not check_addresses
|
||||
) as client:
|
||||
if check_addresses:
|
||||
if method.upper() != "GET":
|
||||
return {"error": "address-checked requests must be GET"}
|
||||
try:
|
||||
resp = get_checked(client, url)
|
||||
except PermissionError as exc:
|
||||
return {"error": str(exc)}
|
||||
else:
|
||||
resp = client.request(
|
||||
method, url, headers=headers, params=params, json=json, auth=auth
|
||||
)
|
||||
ctype = resp.headers.get("content-type", "")
|
||||
data: Any = resp.json() if "json" in ctype.lower() else resp.text
|
||||
if resp.status_code >= 400:
|
||||
@@ -533,7 +558,13 @@ def make_integration_tools(
|
||||
def browser_read_url(url: str, max_chars: int = 20000) -> dict[str, Any]:
|
||||
if not url.lower().startswith(("http://", "https://")):
|
||||
return {"error": "url must start with http:// or https://"}
|
||||
out = _request("GET", url, headers={"User-Agent": "coworker/0.1 (+connector)"})
|
||||
# Model-supplied URL: address-check every hop, same guard as web_fetch.
|
||||
out = _request(
|
||||
"GET",
|
||||
url,
|
||||
headers={"User-Agent": "coworker/0.1 (+connector)"},
|
||||
check_addresses=True,
|
||||
)
|
||||
if "error" in out:
|
||||
return out
|
||||
data = out["data"]
|
||||
|
||||
@@ -95,6 +95,7 @@ class ConversationStore:
|
||||
"ALTER TABLE sessions ADD COLUMN auto_title TEXT",
|
||||
"ALTER TABLE sessions ADD COLUMN renamed INTEGER DEFAULT 0",
|
||||
"ALTER TABLE sessions ADD COLUMN grants TEXT",
|
||||
"ALTER TABLE sessions ADD COLUMN compaction TEXT",
|
||||
):
|
||||
try:
|
||||
self._conn.execute(ddl)
|
||||
@@ -191,13 +192,14 @@ class ConversationStore:
|
||||
title = record.title or title_from(record.messages)
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO sessions (session_id, workspace, model, mode, title, agent, n_msgs, messages, extra_roots, grants, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, CURRENT_TIMESTAMP)
|
||||
INSERT INTO sessions (session_id, workspace, model, mode, title, agent, n_msgs, messages, extra_roots, grants, compaction, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
workspace = excluded.workspace, model = excluded.model, mode = excluded.mode,
|
||||
title = COALESCE(sessions.title, excluded.title), agent = excluded.agent,
|
||||
n_msgs = excluded.n_msgs, messages = NULL, extra_roots = excluded.extra_roots,
|
||||
grants = excluded.grants, updated_at = CURRENT_TIMESTAMP
|
||||
grants = excluded.grants, compaction = excluded.compaction,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(
|
||||
sid,
|
||||
@@ -209,6 +211,7 @@ class ConversationStore:
|
||||
len(record.messages),
|
||||
json.dumps(record.extra_roots or []),
|
||||
json.dumps(record.grants or {}),
|
||||
json.dumps(record.compaction or {}),
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
@@ -241,6 +244,10 @@ class ConversationStore:
|
||||
row["extra_roots"] if "extra_roots" in row.keys() else None
|
||||
),
|
||||
grants=_load_grants(row["grants"] if "grants" in row.keys() else None),
|
||||
# Auto-compaction state (OPE-27) — same defensive parse as grants.
|
||||
compaction=_load_grants(
|
||||
row["compaction"] if "compaction" in row.keys() else None
|
||||
),
|
||||
pinned=bool(row["pinned"]),
|
||||
archived=bool(row["archived"]),
|
||||
origin=row["origin"],
|
||||
|
||||
+162
-4
@@ -19,6 +19,7 @@ from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, AsyncIterator, Awaitable, Callable, Optional
|
||||
|
||||
from . import compaction as _compaction
|
||||
from .events import Event, EventType
|
||||
from .permissions import Mode, PermissionEngine
|
||||
from .providers import AssistantTurn, ProviderClient, ToolCall
|
||||
@@ -103,6 +104,14 @@ class TurnEngine:
|
||||
# (answerable inline in a live session or from the Inbox when unattended). None on surfaces
|
||||
# that can't ask (the tool then no-ops).
|
||||
self.question_asker = question_asker
|
||||
# Auto-compaction (OPE-27) — set post-construction by the surface/manager so the
|
||||
# constructor footprint stays put. `compaction_settings` is a live getter (Settings
|
||||
# changes apply without a rebuild); `is_attended` gates the failure prompt (None →
|
||||
# treat as unattended: never park a background run on internal bookkeeping).
|
||||
self.compaction_state: Optional[_compaction.CompactionState] = None
|
||||
self.compaction_settings: Optional[Callable[[], dict[str, Any]]] = None
|
||||
self.is_attended: Optional[Callable[[], bool]] = None
|
||||
self._last_context_tokens: Optional[int] = None
|
||||
self.audit_context: dict[str, Any] = {}
|
||||
if instructions and not (
|
||||
self.messages and self.messages[0].get("role") == "system"
|
||||
@@ -154,13 +163,20 @@ class TurnEngine:
|
||||
|
||||
# -- main loop --------------------------------------------------------------
|
||||
async def run(
|
||||
self, user_input: "str | list", *, source: Optional[dict[str, Any]] = None
|
||||
self,
|
||||
user_input: "str | list",
|
||||
*,
|
||||
source: Optional[dict[str, Any]] = None,
|
||||
display: Optional[str] = None,
|
||||
) -> AsyncIterator[Event]:
|
||||
# `user_input` is a string, or OpenAI content-parts (text + image_url) for attachments.
|
||||
# `source` (a MessageSource dict) is a display-only sidecar for connector messages: it
|
||||
# rides on the persisted user message + the TURN_START event, but is stripped before the
|
||||
# message reaches a provider (see `_outbound_messages`). `content` stays the framed text.
|
||||
# `ts` (unix seconds, stamped on every appended message) is the same kind of sidecar.
|
||||
# `display` is the same split for force-run skills (SKILLS-SPEC §4.1 #3): the user's
|
||||
# literal "/skill …" line for the transcript, while `content` carries the model-facing
|
||||
# framing. `ts` (unix seconds, stamped on every appended message) is the same kind of
|
||||
# sidecar.
|
||||
message: dict[str, Any] = {
|
||||
"role": "user",
|
||||
"content": user_input,
|
||||
@@ -168,11 +184,15 @@ class TurnEngine:
|
||||
}
|
||||
if source is not None:
|
||||
message["source"] = source
|
||||
if display is not None:
|
||||
message["_display"] = display
|
||||
self.messages.append(message)
|
||||
self._cancel.clear()
|
||||
data: dict[str, Any] = {"input": user_input}
|
||||
if source is not None:
|
||||
data["source"] = source
|
||||
if display is not None:
|
||||
data["display"] = display
|
||||
yield Event(EventType.TURN_START, data)
|
||||
async for event in self._loop():
|
||||
yield event
|
||||
@@ -302,6 +322,18 @@ class TurnEngine:
|
||||
return
|
||||
iterations += 1
|
||||
|
||||
# Auto-compaction checkpoint (OPE-27): between tool turns and before a new
|
||||
# turn's first call. Deliberately no "wrap up" warning to the model. The
|
||||
# COMPACTING signal precedes the (multi-second) summarizer call so surfaces
|
||||
# can show progress instead of a silent stall.
|
||||
notice = None
|
||||
if self._compaction_due():
|
||||
yield Event(EventType.COMPACTING, {})
|
||||
notice = await self._compact_now()
|
||||
if notice:
|
||||
self._append_notice("compacted", notice)
|
||||
yield Event(EventType.COMPACTED, {"text": notice})
|
||||
|
||||
turn: Optional[AssistantTurn] = None
|
||||
streamed: list[str] = []
|
||||
streamed_reasoning: list[str] = []
|
||||
@@ -329,6 +361,17 @@ class TurnEngine:
|
||||
if chunk.turn is not None:
|
||||
turn = chunk.turn
|
||||
except Exception as exc: # provider failure
|
||||
# A raw context-overflow 400 (compaction mispredicted, e.g. the estimate
|
||||
# path) routes into the compaction policy instead of surfacing. The retry
|
||||
# is progress-guarded: each pass moves the boundary forward or gives up,
|
||||
# so a model that keeps overflowing still terminates in the error path.
|
||||
if _compaction.is_context_overflow(exc) and not self._cancel.is_set():
|
||||
yield Event(EventType.COMPACTING, {})
|
||||
notice = await self._compact_now(force=True)
|
||||
if notice:
|
||||
self._append_notice("compacted", notice)
|
||||
yield Event(EventType.COMPACTED, {"text": notice})
|
||||
continue
|
||||
# Same contract as the stop path below: the partial the user watched
|
||||
# arrive survives the failure.
|
||||
if streamed or streamed_reasoning:
|
||||
@@ -352,6 +395,10 @@ class TurnEngine:
|
||||
return
|
||||
if turn is None:
|
||||
turn = AssistantTurn()
|
||||
if turn.usage is not None:
|
||||
# The trigger signal: the prompt-side total that actually occupied the
|
||||
# window on this round-trip (estimate fallback when never reported).
|
||||
self._last_context_tokens = turn.usage.context_tokens
|
||||
|
||||
self.messages.append(_assistant_message(turn, model=self.model))
|
||||
payload: dict[str, Any] = {
|
||||
@@ -386,6 +433,104 @@ class TurnEngine:
|
||||
if self._steering:
|
||||
self._inject_steering()
|
||||
|
||||
# -- auto-compaction (OPE-27) ------------------------------------------------
|
||||
def _compaction_config(self) -> dict[str, Any]:
|
||||
cfg = dict(self.compaction_settings() or {}) if self.compaction_settings else {}
|
||||
if not cfg.get("context_window"):
|
||||
from .providers.matrix import model_context_windows
|
||||
|
||||
cfg["context_window"] = model_context_windows().get(self.model)
|
||||
cfg.setdefault("threshold_pct", _compaction.DEFAULT_THRESHOLD_PCT)
|
||||
cfg.setdefault("cap_tokens", _compaction.DEFAULT_CAP_TOKENS)
|
||||
return cfg
|
||||
|
||||
def _compaction_due(self) -> bool:
|
||||
"""The trigger check alone — cheap and side-effect free, so the loop can emit
|
||||
the COMPACTING signal before committing to the (slow) summarizer call."""
|
||||
cfg = self._compaction_config()
|
||||
if cfg.get("enabled") is False:
|
||||
return False
|
||||
signal = self._last_context_tokens or _compaction.estimate_tokens(
|
||||
self._outbound_messages()
|
||||
)
|
||||
return _compaction.should_compact(
|
||||
signal,
|
||||
cfg.get("context_window"),
|
||||
threshold_pct=float(cfg["threshold_pct"]),
|
||||
cap_tokens=int(cfg["cap_tokens"]),
|
||||
)
|
||||
|
||||
async def _compact_now(self, *, force: bool = False) -> Optional[str]:
|
||||
"""Run the compaction policy. Callers gate on `_compaction_due()` (or `force`,
|
||||
the overflow path). Returns the user-facing notice text when the outbound view
|
||||
changed, else None. Failure policy per spec: retry once (both modes); attended →
|
||||
Retry / Trim prompt; unattended → auto-trim and continue (never park a run on
|
||||
bookkeeping)."""
|
||||
cfg = self._compaction_config()
|
||||
pct = float(cfg["threshold_pct"])
|
||||
cap = int(cfg["cap_tokens"])
|
||||
window = cfg.get("context_window")
|
||||
keep = int(
|
||||
_compaction.KEEP_RECENT_FRACTION
|
||||
* _compaction.trigger_tokens(window, threshold_pct=pct, cap_tokens=cap)
|
||||
)
|
||||
model = str(cfg.get("model") or "") or self.model
|
||||
|
||||
def _build() -> Optional[_compaction.CompactionState]:
|
||||
return _compaction.build_state(
|
||||
self.messages,
|
||||
provider=self.provider,
|
||||
model=model,
|
||||
keep_tokens=keep,
|
||||
prior=self.compaction_state,
|
||||
)
|
||||
|
||||
state: Optional[_compaction.CompactionState] = None
|
||||
failed = False
|
||||
for _attempt in range(2): # first try + the unconditional single retry
|
||||
try:
|
||||
state = await asyncio.to_thread(_build)
|
||||
failed = False
|
||||
break
|
||||
except Exception:
|
||||
failed = True
|
||||
if failed and self.question_asker is not None and self.is_attended and self.is_attended():
|
||||
while True:
|
||||
answer = await self._interruptible(
|
||||
self.question_asker(
|
||||
{
|
||||
"question": (
|
||||
"Context compaction failed — the summarizer couldn't "
|
||||
"condense this session's history. How should I proceed?"
|
||||
),
|
||||
"options": ["Retry", "Trim oldest 10%"],
|
||||
"allow_text": False,
|
||||
"header": "Compaction",
|
||||
},
|
||||
None,
|
||||
),
|
||||
interrupted=None,
|
||||
)
|
||||
if not answer or answer.get("answer") != "Retry":
|
||||
break
|
||||
try:
|
||||
state = await asyncio.to_thread(_build)
|
||||
failed = False
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if state is not None:
|
||||
self.compaction_state = state
|
||||
self._last_context_tokens = None # stale once the outbound view shrank
|
||||
return "Context compacted — earlier turns were summarized"
|
||||
if failed or force:
|
||||
trimmed = _compaction.trim_state(self.messages, prior=self.compaction_state)
|
||||
if trimmed is not None:
|
||||
self.compaction_state = trimmed
|
||||
self._last_context_tokens = None
|
||||
return "Context trimmed — oldest turns dropped (summary unavailable)"
|
||||
return None
|
||||
|
||||
# -- helpers ----------------------------------------------------------------
|
||||
async def _astream(self):
|
||||
"""Bridge the provider's blocking stream generator to the async loop via a
|
||||
@@ -826,6 +971,13 @@ class TurnEngine:
|
||||
from the Inbox when unattended), and return it as the tool result."""
|
||||
args = tool_call.arguments or {}
|
||||
question = str(args.get("question", "")).strip()
|
||||
# Grouped form (OPE-51): `questions` alone is a valid call — the singular field may be
|
||||
# empty. The asker normalizes/validates the entries; here only "is anything asked?".
|
||||
if not question:
|
||||
for entry in args.get("questions") or []:
|
||||
if isinstance(entry, dict) and str(entry.get("question", "")).strip():
|
||||
question = str(entry["question"]).strip()
|
||||
break
|
||||
if self.question_asker is None or not question:
|
||||
result: dict[str, Any] = {
|
||||
"answer": "",
|
||||
@@ -847,7 +999,7 @@ class TurnEngine:
|
||||
"error": "no response",
|
||||
}
|
||||
|
||||
status = "ok" if result.get("answer") else "denied"
|
||||
status = "ok" if (result.get("answer") or result.get("answers")) else "denied"
|
||||
self.messages.append(_tool_result_message(tool_call, result))
|
||||
self._audit(
|
||||
tool_call,
|
||||
@@ -893,13 +1045,19 @@ class TurnEngine:
|
||||
# one. Whole `notice` messages (error/interrupted/model-switch markers) are
|
||||
# display-only too: dropped entirely.
|
||||
_SIDECARS = ("source", "_display", "ts", "reasoning", "usage")
|
||||
# Auto-compaction (OPE-27): everything before the boundary is represented by the
|
||||
# compacted block. Outbound-only — the canonical history stays intact — and the
|
||||
# block+tail are byte-stable between turns, so prompt caching keeps working.
|
||||
source_messages = _compaction.apply_to_outbound(
|
||||
self.messages, self.compaction_state
|
||||
)
|
||||
out = [
|
||||
(
|
||||
{k: v for k, v in msg.items() if k not in _SIDECARS}
|
||||
if any(s in msg for s in _SIDECARS)
|
||||
else msg
|
||||
)
|
||||
for msg in self.messages
|
||||
for msg in source_messages
|
||||
if msg.get("role") != "notice"
|
||||
]
|
||||
# PDF attachments (stored as `file` parts) are adapted to the ACTIVE model right
|
||||
|
||||
@@ -31,6 +31,8 @@ class EventType(str, Enum):
|
||||
TURN_END = "turn_end"
|
||||
ERROR = "error"
|
||||
INTERRUPTED = "interrupted"
|
||||
COMPACTING = "compacting" # compaction started — surfaces show a transient signal
|
||||
COMPACTED = "compacted" # outbound history was compacted (summary or trim)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
+17
-1
@@ -78,11 +78,19 @@ class InboxItem:
|
||||
tool_call_id: Optional[str] = None
|
||||
# Question metadata (ask_user): optional quick-reply choices + a free-text escape, mirroring
|
||||
# the structured-but-always-answerable shape of Claude Code's AskUserQuestion.
|
||||
options: list[str] = field(default_factory=list)
|
||||
# An option is a plain string OR a rich {label, description, recommended, preview} object
|
||||
# (OPE-51); old persisted items hold strings and stay valid.
|
||||
options: list = field(default_factory=list)
|
||||
allow_text: bool = (
|
||||
True # accept a typed answer even when options exist (the "Other" escape)
|
||||
)
|
||||
multi: bool = False # allow choosing more than one option
|
||||
header: str = "" # short chip label for the card ("Region")
|
||||
# Grouped form (OPE-51): up to 4 {question, header, options, allow_text, multi} entries
|
||||
# rendered as a stepper. When non-empty the singular title/options fields above still hold
|
||||
# the FIRST question (so old surfaces and channel mirrors degrade to something sensible),
|
||||
# and the resolution is a JSON object string keyed by header-or-question.
|
||||
questions: list[dict] = field(default_factory=list)
|
||||
# Kind-specific payload (directory: suggested path/writable; plan: the plan text; …).
|
||||
data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@@ -126,6 +134,8 @@ class InboxStore:
|
||||
options=None,
|
||||
allow_text: bool = True,
|
||||
multi: bool = False,
|
||||
header: str = "",
|
||||
questions=None,
|
||||
tool_call_id: Optional[str] = None,
|
||||
) -> InboxItem:
|
||||
# Idempotent by (session_id, tool_call_id): a durable resume re-raises the same prompt, and
|
||||
@@ -146,6 +156,8 @@ class InboxStore:
|
||||
options=list(options or []),
|
||||
allow_text=bool(allow_text),
|
||||
multi=bool(multi),
|
||||
header=str(header or ""),
|
||||
questions=list(questions or []),
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
with self._lock:
|
||||
@@ -194,6 +206,8 @@ class InboxStore:
|
||||
options=None,
|
||||
allow_text=True,
|
||||
multi=False,
|
||||
header="",
|
||||
questions=None,
|
||||
tool_call_id=None,
|
||||
) -> InboxItem:
|
||||
return self.add(
|
||||
@@ -206,6 +220,8 @@ class InboxStore:
|
||||
options=options,
|
||||
allow_text=allow_text,
|
||||
multi=multi,
|
||||
header=header,
|
||||
questions=questions,
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ DEFAULT_INBOX = "default"
|
||||
# to OpenWorker (2026-07-22); the legacy [ocw:…] spelling stays parseable so replies to
|
||||
# messages sent before the rename still resolve.
|
||||
_ID_TOKEN = re.compile(r"\[o(?:c)?w:([0-9a-f]{6,})\]")
|
||||
# Whole words only — substring matching resolved "disallow" as allow and "note" as deny.
|
||||
_ALLOW_WORDS = re.compile(r"\b(?:approve|approved|allow|allowed|yes)\b")
|
||||
_DENY_WORDS = re.compile(r"\b(?:deny|denied|reject|rejected|no)\b")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -130,9 +133,9 @@ def resolve_from_reply(
|
||||
return None
|
||||
item_id = m.group(1)
|
||||
lowered = reply.lower()
|
||||
if any(w in lowered for w in ("approve", "allow", "yes", "👍", "✅")):
|
||||
if _ALLOW_WORDS.search(lowered) or "👍" in reply or "✅" in reply:
|
||||
resolution = "allow"
|
||||
elif any(w in lowered for w in ("deny", "reject", "no", "👎", "❌")):
|
||||
elif _DENY_WORDS.search(lowered) or "👎" in reply or "❌" in reply:
|
||||
resolution = "deny"
|
||||
else:
|
||||
resolution = _ID_TOKEN.sub("", reply).strip() # free-text answer to a question
|
||||
|
||||
@@ -17,6 +17,7 @@ from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from .inbox import KIND_APPROVAL, KIND_QUESTION
|
||||
from .tools.ask import option_label
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -48,7 +49,15 @@ def buttons_for(item) -> list[Button]:
|
||||
Button("Approve", encode(item.id, "allow")),
|
||||
Button("Deny", encode(item.id, "deny")),
|
||||
]
|
||||
if item.kind == KIND_QUESTION and getattr(item, "questions", None):
|
||||
# Grouped questions (OPE-51): one button row can't answer 2+ questions — send plain text
|
||||
# with the open-the-app hint instead.
|
||||
return []
|
||||
if item.kind == KIND_QUESTION and getattr(item, "options", None):
|
||||
# One button per option; the resolution IS the chosen option text (what the agent gets).
|
||||
return [Button(opt, encode(item.id, opt)) for opt in item.options]
|
||||
# One button per option; the resolution IS the chosen option's label (what the agent
|
||||
# gets). Rich {label, description, …} options button as their label.
|
||||
return [
|
||||
Button(option_label(opt), encode(item.id, option_label(opt)))
|
||||
for opt in item.options
|
||||
]
|
||||
return []
|
||||
|
||||
+25
-7
@@ -1,7 +1,9 @@
|
||||
"""MCP server config — the standard `mcpServers` JSON, layered global + workspace.
|
||||
|
||||
Global: ~/.config/coworker/mcp.json
|
||||
Workspace: <workspace>/.coworker/mcp.json (overrides global on name clash)
|
||||
Workspace: <workspace>/.coworker/mcp.json (overrides global on name clash,
|
||||
but only after the user trusts that workspace — same gate as
|
||||
repository `allowed_commands`)
|
||||
|
||||
Paste-compatible with Claude Desktop / Cursor / Codex. `${VAR}` refs in command/args/env/
|
||||
url/headers are resolved at load time via the SecretStore (env + local `.env`). REST edits
|
||||
@@ -50,9 +52,15 @@ def _read(path: Path) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _config_paths(workspace: Optional[str | Path]) -> list[Path]:
|
||||
def _config_paths(
|
||||
workspace: Optional[str | Path], *, workspace_trusted: bool
|
||||
) -> list[Path]:
|
||||
"""Config files to merge. Workspace MCP is executable provenance (stdio spawn),
|
||||
so an untrusted repo's `.coworker/mcp.json` is never read — cloning alone must
|
||||
not be enough to define processes that run at session open.
|
||||
"""
|
||||
paths = [global_mcp_path()]
|
||||
if workspace:
|
||||
if workspace and workspace_trusted:
|
||||
paths.append(Path(workspace).expanduser() / ".coworker" / "mcp.json")
|
||||
return paths
|
||||
|
||||
@@ -79,15 +87,25 @@ def _parse(name: str, raw: dict[str, Any], secrets: SecretStore) -> MCPServerDef
|
||||
|
||||
|
||||
def load_mcp_servers(
|
||||
workspace: Optional[str | Path] = None, *, secrets: Optional[SecretStore] = None
|
||||
workspace: Optional[str | Path] = None,
|
||||
*,
|
||||
secrets: Optional[SecretStore] = None,
|
||||
workspace_trusted: bool = False,
|
||||
) -> list[MCPServerDef]:
|
||||
"""Merge global + workspace `mcpServers` (workspace wins) into parsed server defs."""
|
||||
"""Merge global + (when trusted) workspace `mcpServers` into parsed server defs.
|
||||
|
||||
Only trusted workspaces contribute — the same consent boundary as repository
|
||||
``allowed_commands`` — and **global wins on name clash**, so even a trusted repo
|
||||
cannot silently redefine a global server by reusing its name. ``${VAR}`` refs in
|
||||
a workspace def are resolved from the user's env, which is acceptable only because
|
||||
the workspace is trusted; untrusted workspaces are never read.
|
||||
"""
|
||||
secrets = secrets or SecretStore()
|
||||
merged: dict[str, dict[str, Any]] = {}
|
||||
for path in _config_paths(workspace):
|
||||
for path in _config_paths(workspace, workspace_trusted=workspace_trusted):
|
||||
for name, raw in (_read(path).get("mcpServers") or {}).items():
|
||||
if isinstance(raw, dict):
|
||||
merged[name] = raw
|
||||
merged.setdefault(name, raw) # global first → global wins on clash
|
||||
return [_parse(name, raw, secrets) for name, raw in merged.items()]
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from .base import (
|
||||
from .capabilities import capabilities_for
|
||||
from .gemini_provider import GeminiProvider
|
||||
from .openai_provider import OpenAIProvider, resolve_api_key
|
||||
from .openai_responses import OpenAIResponsesProvider
|
||||
from .registry import (
|
||||
ProviderDescriptor,
|
||||
ProviderField,
|
||||
@@ -34,6 +35,7 @@ __all__ = [
|
||||
"BedrockProvider",
|
||||
"GeminiProvider",
|
||||
"OpenAIProvider",
|
||||
"OpenAIResponsesProvider",
|
||||
"VertexProvider",
|
||||
"resolve_api_key",
|
||||
"capabilities_for",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Provider-agnostic model access layer.
|
||||
|
||||
The runtime never imports a provider SDK directly — it talks to a `ProviderClient`.
|
||||
v1 ships `OpenAIProvider` (OpenAI SDK, `chat.completions` only); an `AISuiteProvider`
|
||||
slots in later (P12) without touching the engine, since aisuite is OpenAI-API-shaped.
|
||||
Implementations: `OpenAIResponsesProvider` (native OpenAI via `/v1/responses`),
|
||||
`OpenAIProvider` (Chat Completions — the compat world), and the native
|
||||
Anthropic/Gemini/Bedrock/Vertex providers, all selected by the registry/router.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -112,7 +112,15 @@ MATRIX: dict[str, ModelEntry] = {
|
||||
# -- resellers (their model namespaces, verbatim) -----------------------------
|
||||
"together:thinkingmachines/Inkling": ModelEntry("Inkling · via Together"),
|
||||
"together:zai-org/GLM-5.2": ModelEntry("GLM-5.2 · via Together", _AGENTIC, 128_000),
|
||||
# Kimi K3 (2026-07-16) is not on Together yet — weights land ~07-27; revisit then.
|
||||
# Kimi K3 on Together (landed late July 2026): 1M window, native vision; PDFs
|
||||
# unverified over the compat surface (falls back via pdf_support.py, like Muse Spark).
|
||||
"together:moonshotai/Kimi-K3": ModelEntry(
|
||||
"Kimi K3 · via Together",
|
||||
ModelCapabilities(
|
||||
tools=True, vision=True, parallel_tool_calls=True, streaming=True
|
||||
),
|
||||
1_000_000,
|
||||
),
|
||||
"together:moonshotai/Kimi-K2.7-Code": ModelEntry(
|
||||
"Kimi K2.7 Code · via Together", _AGENTIC, 256_000
|
||||
),
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""OpenAI provider — the v1 model access implementation.
|
||||
"""OpenAI Chat Completions provider — the compat workhorse.
|
||||
|
||||
Uses the OpenAI Python SDK `chat.completions` API only (no Responses/Assistants), so
|
||||
the later swap to aisuite (OpenAI-API-shaped) stays a near drop-in.
|
||||
Uses the OpenAI Python SDK `chat.completions` API only, which is what the entire
|
||||
OpenAI-compatible world implements: the compat vendors (DeepSeek, Z AI, Kimi, …),
|
||||
resellers, Ollama, custom endpoints (Azure OpenAI, vLLM), and the Bedrock/Vertex MaaS
|
||||
paths. Native OpenAI models (the `openai` provider with no custom endpoint) route to
|
||||
`openai_responses.OpenAIResponsesProvider` instead — Chat Completions rejects function
|
||||
tools combined with reasoning on GPT-5.6+, so reasoning + tools needs `/v1/responses`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -40,10 +44,12 @@ def resolve_api_key(secrets: Any = None) -> Optional[str]:
|
||||
|
||||
# GPT-5.6 (2026-07) defaults reasoning_effort to "medium" server-side, and
|
||||
# /v1/chat/completions rejects function tools combined with any effort other than
|
||||
# "none" ("use /v1/responses"). Until we grow a Responses API path, pin effort to
|
||||
# none whenever tools ride along on these models — and when the API rejects a call
|
||||
# with that exact complaint anyway (a future generation, an alias we didn't list),
|
||||
# retry once at effort none so the user gets a working turn instead of a 400.
|
||||
# "none" ("use /v1/responses"). Native OpenAI now routes to the Responses provider,
|
||||
# but GPT-5.6 can still land here through a custom endpoint (Azure OpenAI serves the
|
||||
# same wire), so keep pinning effort to none whenever tools ride along on these
|
||||
# models — and when the API rejects a call with that exact complaint anyway (a future
|
||||
# generation, an alias we didn't list), retry once at effort none so the user gets a
|
||||
# working turn instead of a 400.
|
||||
_EFFORT_ERROR = "function tools with reasoning_effort are not supported"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""OpenAI Responses provider — native OpenAI models via `/v1/responses`.
|
||||
|
||||
Chat Completions rejects function tools combined with any `reasoning_effort` other than
|
||||
`none` on GPT-5.6+ ("use /v1/responses"), which had reasoning pinned OFF for native OpenAI
|
||||
models (see `openai_provider._pin_reasoning_effort`). This provider is the Responses path:
|
||||
reasoning + tools at real effort levels, streamed reasoning summaries (→ the same
|
||||
`reasoning_delta` / `AssistantTurn.reasoning` plumbing the GUI already renders), and
|
||||
chain-of-thought continuity across tool round-trips via `store: false` +
|
||||
`include: ["reasoning.encrypted_content"]` — nothing retained server-side.
|
||||
|
||||
Routing: the `openai` provider entry with NO custom base_url builds this class; a custom
|
||||
endpoint (Azure, vLLM, any OpenAI-compatible gateway) and every compat vendor keep the
|
||||
Chat Completions `OpenAIProvider` (registry.py).
|
||||
|
||||
Like the other native providers, this is mostly a pair of pure converters from the
|
||||
canonical OpenAI-chat-shaped history to Responses `input` items. What the converters
|
||||
must absorb:
|
||||
|
||||
- The system prompt is the `instructions` request field, not a message role.
|
||||
- Assistant tool calls are top-level `function_call` items; tool results are
|
||||
`function_call_output` items paired by `call_id` (ids only need to pair up, so foreign
|
||||
`toolu_…` ids from a mid-conversation provider switch are fine).
|
||||
- Tool schemas are FLAT (`{"type": "function", "name", …}` — no nested `function` key).
|
||||
- Reasoning continuity: the raw output items (reasoning item with `encrypted_content`,
|
||||
`function_call` items with their ids) ride the canonical assistant message as the
|
||||
`_openai` sidecar (see providers/base.py). Present → replayed verbatim for exact CoT
|
||||
continuity; absent (history from another provider) → items are synthesized from the
|
||||
canonical fields. Reasoning items WITHOUT `encrypted_content` never enter the sidecar:
|
||||
with `store: false` the server can't resolve them and would reject the replay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from .base import (
|
||||
AssistantTurn,
|
||||
ModelCapabilities,
|
||||
ProviderClient,
|
||||
StreamChunk,
|
||||
ToolCall,
|
||||
)
|
||||
from .capabilities import capabilities_for
|
||||
from .openai_provider import resolve_api_key
|
||||
|
||||
# Request params passed through from model settings; everything else (frequency_penalty,
|
||||
# reasoning_effort — no effort knob in v1, the server default rides) is dropped.
|
||||
_SETTINGS_WHITELIST = {
|
||||
"temperature",
|
||||
"top_p",
|
||||
"max_output_tokens",
|
||||
"tool_choice",
|
||||
"parallel_tool_calls",
|
||||
}
|
||||
|
||||
# "Unsupported parameter: 'temperature' is not supported with this model." — reasoning
|
||||
# models reject sampling params; non-reasoning models reject `reasoning`/`include`. The
|
||||
# server names exactly one offender per error, so each retry drops exactly that.
|
||||
_UNSUPPORTED_PARAM = re.compile(r"unsupported (?:parameter|value)s?:?\s*'([^']+)'")
|
||||
|
||||
|
||||
def _param_fix_retry(kwargs: dict[str, Any], exc: Exception) -> dict[str, Any]:
|
||||
"""Kwargs for the one retry an unsupported-parameter error earns, or re-raise.
|
||||
|
||||
Same contract as the Chat Completions retries: fix exactly what the server named.
|
||||
A dotted name (`reasoning.summary`) drops its top-level param.
|
||||
"""
|
||||
match = _UNSUPPORTED_PARAM.search(str(exc).lower())
|
||||
if match:
|
||||
param = match.group(1).split(".", 1)[0].split("[", 1)[0]
|
||||
if param in kwargs and param not in ("model", "input"):
|
||||
fixed = dict(kwargs)
|
||||
del fixed[param]
|
||||
return fixed
|
||||
raise exc
|
||||
|
||||
|
||||
def _user_content(content: Any) -> Any:
|
||||
"""User content (str or OpenAI chat parts) → Responses content (str or input parts)."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
parts: list[dict[str, Any]] = []
|
||||
for part in content or []:
|
||||
kind = part.get("type") if isinstance(part, dict) else None
|
||||
if kind == "text":
|
||||
parts.append({"type": "input_text", "text": part.get("text") or ""})
|
||||
elif kind == "image_url":
|
||||
url = (part.get("image_url") or {}).get("url") or ""
|
||||
parts.append({"type": "input_image", "image_url": url})
|
||||
elif kind == "file":
|
||||
file = part.get("file") or {}
|
||||
entry: dict[str, Any] = {"type": "input_file"}
|
||||
if file.get("filename"):
|
||||
entry["filename"] = file["filename"]
|
||||
if file.get("file_data"):
|
||||
entry["file_data"] = file["file_data"]
|
||||
parts.append(entry)
|
||||
return parts
|
||||
|
||||
|
||||
def _synthesized_items(message: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""An assistant message WITHOUT a usable `_openai` sidecar (history produced by another
|
||||
provider before a switch) → items rebuilt from the canonical fields."""
|
||||
items: list[dict[str, Any]] = []
|
||||
text = message.get("content")
|
||||
if isinstance(text, str) and text:
|
||||
items.append({"role": "assistant", "content": text})
|
||||
for call in message.get("tool_calls") or []:
|
||||
function = call.get("function") or {}
|
||||
arguments = function.get("arguments")
|
||||
if not isinstance(arguments, str):
|
||||
arguments = json.dumps(arguments or {})
|
||||
items.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": call.get("id") or "",
|
||||
"name": function.get("name") or "",
|
||||
"arguments": arguments,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def convert_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> tuple[Optional[str], list[dict[str, Any]]]:
|
||||
"""Canonical OpenAI-chat history → (`instructions`, Responses `input` items).
|
||||
|
||||
Leading system messages join into `instructions`; a stray mid-thread system message
|
||||
rides as a system message item. Assistant messages replay their `_openai` sidecar
|
||||
verbatim when present (exact CoT continuity), else synthesize from canonical fields.
|
||||
"""
|
||||
system_parts: list[str] = []
|
||||
index = 0
|
||||
while index < len(messages) and messages[index].get("role") == "system":
|
||||
content = messages[index].get("content")
|
||||
if isinstance(content, str) and content:
|
||||
system_parts.append(content)
|
||||
index += 1
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for message in messages[index:]:
|
||||
role = message.get("role")
|
||||
if role == "system":
|
||||
text = message.get("content") or ""
|
||||
if text:
|
||||
items.append({"role": "system", "content": text})
|
||||
elif role == "user":
|
||||
content = _user_content(message.get("content"))
|
||||
if content:
|
||||
items.append({"role": "user", "content": content})
|
||||
elif role == "assistant":
|
||||
sidecar = message.get("_openai") or {}
|
||||
replay = sidecar.get("items") or []
|
||||
if replay:
|
||||
items.extend(replay)
|
||||
else:
|
||||
items.extend(_synthesized_items(message))
|
||||
elif role == "tool":
|
||||
content = message.get("content")
|
||||
items.append(
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": message.get("tool_call_id") or "",
|
||||
"output": content if isinstance(content, str) else str(content or ""),
|
||||
}
|
||||
)
|
||||
|
||||
return ("\n\n".join(system_parts) or None), items
|
||||
|
||||
|
||||
def convert_tools(tools: Optional[list[dict[str, Any]]]) -> list[dict[str, Any]]:
|
||||
"""OpenAI chat function schemas → Responses FLAT tool entries (no nested `function`)."""
|
||||
converted: list[dict[str, Any]] = []
|
||||
for tool in tools or []:
|
||||
function = (tool or {}).get("function") or {}
|
||||
name = function.get("name")
|
||||
if not name:
|
||||
continue
|
||||
entry: dict[str, Any] = {"type": "function", "name": name}
|
||||
if function.get("description"):
|
||||
entry["description"] = function["description"]
|
||||
if function.get("parameters") is not None:
|
||||
entry["parameters"] = function["parameters"]
|
||||
converted.append(entry)
|
||||
return converted
|
||||
|
||||
|
||||
def _dump(value: Any) -> Any:
|
||||
"""An output item (SDK model, dict, or test namespace) → plain jsonl-safe data."""
|
||||
if isinstance(value, dict):
|
||||
return {k: _dump(v) for k, v in value.items() if v is not None}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_dump(v) for v in value]
|
||||
dump = getattr(value, "model_dump", None)
|
||||
if callable(dump):
|
||||
return dump(exclude_none=True)
|
||||
if hasattr(value, "__dict__"): # SimpleNamespace fakes in tests
|
||||
return {k: _dump(v) for k, v in vars(value).items() if v is not None}
|
||||
return value
|
||||
|
||||
|
||||
def _parse_arguments(raw: Any) -> dict[str, Any]:
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
return parsed if isinstance(parsed, dict) else {"_raw": raw}
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
# Surface unparseable arguments rather than dropping the call; the engine
|
||||
# can return a tool-error so the model corrects itself.
|
||||
return {"_raw": raw}
|
||||
|
||||
|
||||
def _sidecar_extras(items: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Output items → the `_openai` sidecar, or {} when replay would add nothing.
|
||||
|
||||
Reasoning items without `encrypted_content` are dropped: under `store: false` the
|
||||
server can't resolve them by id and rejects the replay. The sidecar is only worth
|
||||
persisting when something beyond plain answer text needs continuity.
|
||||
"""
|
||||
kept = [
|
||||
item
|
||||
for item in items
|
||||
if item.get("type") != "reasoning" or item.get("encrypted_content")
|
||||
]
|
||||
if any(item.get("type") in ("reasoning", "function_call") for item in kept):
|
||||
return {"_openai": {"items": kept}}
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_response(response: Any) -> AssistantTurn:
|
||||
"""One Responses result → an AssistantTurn (+ `_openai` extras)."""
|
||||
items = [_dump(item) for item in getattr(response, "output", None) or []]
|
||||
texts: list[str] = []
|
||||
summaries: list[str] = []
|
||||
tool_calls: list[ToolCall] = []
|
||||
for item in items:
|
||||
kind = item.get("type")
|
||||
if kind == "message" or (kind is None and "content" in item):
|
||||
content = item.get("content")
|
||||
if isinstance(content, str):
|
||||
texts.append(content)
|
||||
else:
|
||||
for part in content or []:
|
||||
if part.get("type") == "output_text" and part.get("text"):
|
||||
texts.append(part["text"])
|
||||
elif kind == "reasoning":
|
||||
for part in item.get("summary") or []:
|
||||
text = part.get("text") if isinstance(part, dict) else part
|
||||
if text:
|
||||
summaries.append(text)
|
||||
elif kind == "function_call":
|
||||
tool_calls.append(
|
||||
ToolCall(
|
||||
id=item.get("call_id") or item.get("id") or "",
|
||||
name=item.get("name") or "",
|
||||
arguments=_parse_arguments(item.get("arguments")),
|
||||
)
|
||||
)
|
||||
|
||||
incomplete = _dump(getattr(response, "incomplete_details", None)) or {}
|
||||
if tool_calls:
|
||||
finish = "tool_calls"
|
||||
elif incomplete.get("reason") == "max_output_tokens":
|
||||
finish = "length"
|
||||
else:
|
||||
finish = "stop"
|
||||
|
||||
return AssistantTurn(
|
||||
text="".join(texts) or None,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish,
|
||||
raw=response,
|
||||
reasoning="".join(summaries) or None,
|
||||
extras=_sidecar_extras(items),
|
||||
)
|
||||
|
||||
|
||||
class OpenAIResponsesProvider(ProviderClient):
|
||||
def __init__(
|
||||
self,
|
||||
client: Any = None,
|
||||
*,
|
||||
default_model: str = "gpt-5.6-sol",
|
||||
api_key: Optional[str] = None,
|
||||
secrets: Any = None,
|
||||
):
|
||||
# Same deferred-client contract as OpenAIProvider: built lazily so an engine can be
|
||||
# assembled before any key exists; key resolves at call time (explicit → env →
|
||||
# SecretStore). Tests inject a `client` directly. No base_url — a custom endpoint
|
||||
# routes to the Chat Completions provider instead (registry.py).
|
||||
self._client = client
|
||||
self._api_key = api_key
|
||||
self._secrets = secrets
|
||||
self.default_model = default_model
|
||||
|
||||
def _ensure_client(self) -> Any:
|
||||
if self._client is None:
|
||||
# Lazy import so the SDK is only required when actually talking to OpenAI.
|
||||
from openai import OpenAI
|
||||
|
||||
key = self._api_key or resolve_api_key(self._secrets)
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
"No model API key configured. Set OPENAI_API_KEY in the environment, "
|
||||
"or add your key in Manage → Settings."
|
||||
)
|
||||
self._client = OpenAI(api_key=key)
|
||||
return self._client
|
||||
|
||||
def _request_kwargs(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]],
|
||||
settings: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
instructions, items = convert_messages(messages)
|
||||
if "max_tokens" in settings and "max_output_tokens" not in settings:
|
||||
settings = {**settings, "max_output_tokens": settings["max_tokens"]}
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"input": items,
|
||||
# Stateless: nothing retained server-side; the encrypted reasoning rides the
|
||||
# `_openai` sidecar instead, and summaries feed the GUI's thinking display.
|
||||
"store": False,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"reasoning": {"summary": "auto"},
|
||||
**{k: v for k, v in settings.items() if k in _SETTINGS_WHITELIST},
|
||||
}
|
||||
if instructions:
|
||||
kwargs["instructions"] = instructions
|
||||
if tools:
|
||||
converted = convert_tools(tools)
|
||||
if converted:
|
||||
kwargs["tools"] = converted
|
||||
return kwargs
|
||||
|
||||
def _create(self, client: Any, kwargs: dict[str, Any]) -> Any:
|
||||
# Up to three param-fix retries: sampling params, `reasoning`, and `include` can
|
||||
# each need dropping depending on the model (reasoning vs not).
|
||||
for _ in range(3):
|
||||
try:
|
||||
return client.responses.create(**kwargs)
|
||||
except Exception as exc:
|
||||
kwargs = _param_fix_retry(kwargs, exc)
|
||||
return client.responses.create(**kwargs)
|
||||
|
||||
def complete(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
**settings: Any,
|
||||
) -> AssistantTurn:
|
||||
kwargs = self._request_kwargs(
|
||||
model=model, messages=messages, tools=tools, settings=settings
|
||||
)
|
||||
response = self._create(self._ensure_client(), kwargs)
|
||||
return _parse_response(response)
|
||||
|
||||
def capabilities(self, model: str) -> ModelCapabilities:
|
||||
return capabilities_for(model)
|
||||
|
||||
def stream(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
**settings: Any,
|
||||
):
|
||||
kwargs = self._request_kwargs(
|
||||
model=model, messages=messages, tools=tools, settings=settings
|
||||
)
|
||||
kwargs["stream"] = True
|
||||
events = self._create(self._ensure_client(), kwargs)
|
||||
|
||||
text_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
final: Optional[Any] = None
|
||||
for event in events:
|
||||
kind = getattr(event, "type", None)
|
||||
if kind == "response.output_text.delta":
|
||||
delta = getattr(event, "delta", None)
|
||||
if delta:
|
||||
text_parts.append(delta)
|
||||
yield StreamChunk(text_delta=delta)
|
||||
elif kind == "response.reasoning_summary_text.delta":
|
||||
delta = getattr(event, "delta", None)
|
||||
if delta:
|
||||
reasoning_parts.append(delta)
|
||||
yield StreamChunk(reasoning_delta=delta)
|
||||
elif kind in ("response.completed", "response.incomplete", "response.failed"):
|
||||
final = getattr(event, "response", None)
|
||||
|
||||
if final is not None:
|
||||
# The terminal event carries the full response — parse it whole so tool
|
||||
# calls, finish reason, and the `_openai` sidecar come from one place.
|
||||
yield StreamChunk(turn=_parse_response(final))
|
||||
else:
|
||||
yield StreamChunk(
|
||||
turn=AssistantTurn(
|
||||
text="".join(text_parts) or None,
|
||||
reasoning="".join(reasoning_parts) or None,
|
||||
)
|
||||
)
|
||||
@@ -6,8 +6,9 @@ GUI, same `to_dict()` shape connectors use) and a `build(profile, secrets)` fact
|
||||
a `ProviderClient`. The `ProviderRouter` selects a descriptor by the `provider:` prefix of a
|
||||
model string and builds (and caches) its client from the matching SecretStore profile.
|
||||
|
||||
Today: `openai` (the default, with an optional custom endpoint that covers Azure OpenAI's
|
||||
`/openai/v1` and any OpenAI-compliant gateway), `anthropic` (native Messages API via
|
||||
Today: `openai` (the default — native models via the Responses API; an optional custom
|
||||
endpoint covering Azure OpenAI's `/openai/v1` and any OpenAI-compliant gateway keeps the
|
||||
Chat Completions path), `anthropic` (native Messages API via
|
||||
`AnthropicProvider`), `gemini` (native Google GenAI API via `GeminiProvider`), `bedrock`
|
||||
(models in the user's own AWS account — Claude natively, everything else via Converse),
|
||||
`vertex` (the user's own GCP project — Gemini and Claude natively, open-weight via the
|
||||
@@ -25,6 +26,7 @@ from .base import ProviderClient
|
||||
from .bedrock_provider import BedrockProvider
|
||||
from .gemini_provider import GeminiProvider
|
||||
from .openai_provider import OpenAIProvider
|
||||
from .openai_responses import OpenAIResponsesProvider
|
||||
from .vertex_provider import VertexProvider
|
||||
|
||||
DEFAULT_OLLAMA_URL = "http://localhost:11434"
|
||||
@@ -111,11 +113,15 @@ def _normalize_ollama_url(url: Optional[str]) -> str:
|
||||
|
||||
|
||||
def _build_openai(profile: dict[str, Any], secrets: Any) -> ProviderClient:
|
||||
# Key resolution stays in OpenAIProvider/resolve_api_key (explicit → env → SecretStore),
|
||||
# so we just hand it the SecretStore. An optional custom endpoint (Azure OpenAI /openai/v1,
|
||||
# OpenRouter, vLLM, …) comes from the stored profile.
|
||||
# Key resolution stays in resolve_api_key (explicit → env → SecretStore), so we just
|
||||
# hand over the SecretStore. Stock OpenAI (no custom endpoint) speaks the Responses
|
||||
# API — the only wire with reasoning + tools on GPT-5.6+. A custom endpoint (Azure
|
||||
# OpenAI /openai/v1, vLLM, any OpenAI-compliant gateway) keeps Chat Completions,
|
||||
# which is what compat servers implement.
|
||||
base_url = ((profile or {}).get("base_url") or "").strip() or None
|
||||
return OpenAIProvider(secrets=secrets, base_url=base_url)
|
||||
if base_url:
|
||||
return OpenAIProvider(secrets=secrets, base_url=base_url)
|
||||
return OpenAIResponsesProvider(secrets=secrets)
|
||||
|
||||
|
||||
def _build_anthropic(profile: dict[str, Any], secrets: Any) -> ProviderClient:
|
||||
|
||||
+125
-13
@@ -8,6 +8,8 @@ proxy so any OpenAI-format client can use the runtime as a backend.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -388,6 +390,29 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
manager.unattended.set(session_id, on)
|
||||
return {"ok": True, "session_id": session_id, "unattended": on}
|
||||
|
||||
@app.get("/v1/sessions/{session_id}/skills")
|
||||
def session_skills(session_id: str, workspace: str = "") -> dict[str, Any]:
|
||||
# The rail's Skills group + the composer popup both read this (SKILLS-SPEC §4.1).
|
||||
return manager.session_skills_view(session_id, workspace or None)
|
||||
|
||||
@app.post("/v1/sessions/{session_id}/skills")
|
||||
def set_session_skill(session_id: str, body: dict) -> dict[str, Any]:
|
||||
# A session mute. `clear` drops the override (inherit again); otherwise explicit
|
||||
# on/off. Nothing on disk changes — Settings owns permanent state.
|
||||
body = body or {}
|
||||
skill = str(body.get("skill", "")).strip()
|
||||
if not skill:
|
||||
return {"ok": False, "error": "skill required"}
|
||||
if body.get("clear"):
|
||||
manager.session_skills.clear(session_id, skill)
|
||||
else:
|
||||
manager.session_skills.set(
|
||||
session_id, skill, bool(body.get("enabled", False))
|
||||
)
|
||||
return manager.session_skills_view(
|
||||
session_id, str(body.get("workspace", "")) or None
|
||||
)
|
||||
|
||||
@app.get("/v1/sessions/{session_id}/connections")
|
||||
def session_connections(session_id: str, persona: str = "") -> dict[str, Any]:
|
||||
# `persona` is the GUI's hint for brand-new sessions (no record yet) — without it the
|
||||
@@ -555,8 +580,45 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
)
|
||||
|
||||
@app.get("/v1/skills")
|
||||
def skills() -> dict[str, Any]:
|
||||
return {"skills": manager.list_skills()}
|
||||
def skills(workspace: str = "") -> dict[str, Any]:
|
||||
return {"skills": manager.list_skills(workspace or None)}
|
||||
|
||||
@app.post("/v1/skills")
|
||||
def create_skill(body: dict) -> dict[str, Any]:
|
||||
return manager.create_skill(body or {})
|
||||
|
||||
@app.patch("/v1/skills/{name}")
|
||||
def update_skill(name: str, body: dict) -> dict[str, Any]:
|
||||
return manager.update_skill(name, body or {})
|
||||
|
||||
@app.delete("/v1/skills/{name}")
|
||||
def delete_skill(name: str, workspace: str = "") -> dict[str, Any]:
|
||||
return manager.delete_skill(name, workspace or None)
|
||||
|
||||
@app.post("/v1/skills/{name}/move")
|
||||
def move_skill(name: str, body: dict) -> dict[str, Any]:
|
||||
return manager.move_skill(name, body or {})
|
||||
|
||||
@app.post("/v1/skills/{name}/reveal")
|
||||
def reveal_skill(name: str, body: dict) -> dict[str, Any]:
|
||||
# §6 "Show folder": open the skill's folder in the OS file manager (local machine).
|
||||
return manager.reveal_skill(name, str((body or {}).get("workspace", "")) or None)
|
||||
|
||||
@app.post("/v1/skills/upload")
|
||||
def stage_skill_upload(body: dict) -> dict[str, Any]:
|
||||
# Stage → preview; nothing is installed until /upload/confirm (SKILLS-SPEC §4.2).
|
||||
data_b64 = str((body or {}).get("data_b64", ""))
|
||||
if not data_b64:
|
||||
return {"ok": False, "error": "No archive supplied."}
|
||||
try:
|
||||
data = base64.b64decode(data_b64, validate=True)
|
||||
except (ValueError, binascii.Error):
|
||||
return {"ok": False, "error": "Invalid archive encoding."}
|
||||
return manager.stage_skill_upload(data, str((body or {}).get("filename", "")))
|
||||
|
||||
@app.post("/v1/skills/upload/confirm")
|
||||
def confirm_skill_upload(body: dict) -> dict[str, Any]:
|
||||
return manager.confirm_skill_upload(body or {})
|
||||
|
||||
@app.get("/v1/workspaces/recent")
|
||||
def recent_workspaces() -> dict[str, Any]:
|
||||
@@ -1393,6 +1455,11 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
# Sidebar: sessions shown per group before "Show more" (owner ask, 2026-07-03).
|
||||
return manager.set_sessions_peek((body or {}).get("sessions_peek", 5))
|
||||
|
||||
@app.post("/v1/settings/context-bar")
|
||||
def settings_set_context_bar(body: dict) -> dict[str, Any]:
|
||||
# Composer: show the context-window fill bar, or just the popover (owner ask).
|
||||
return manager.set_context_bar((body or {}).get("context_bar", True))
|
||||
|
||||
@app.post("/v1/settings/pdf")
|
||||
def settings_set_pdf(body: dict) -> dict[str, Any]:
|
||||
# Token savings (owner ask, 2026-07-17): fallback mode for models without native
|
||||
@@ -1404,6 +1471,17 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
max_mb=b.get("pdf_max_mb"),
|
||||
)
|
||||
|
||||
@app.post("/v1/settings/compaction")
|
||||
def settings_set_compaction(body: dict) -> dict[str, Any]:
|
||||
# Auto-compaction overrides (OPE-27): threshold % of the context window, the
|
||||
# absolute token cap, and the summarizer-model pin ("" → session's own model).
|
||||
b = body or {}
|
||||
return manager.set_compaction_settings(
|
||||
threshold_pct=b.get("compaction_threshold_pct"),
|
||||
cap_tokens=b.get("compaction_cap_tokens"),
|
||||
model=b.get("compaction_model"),
|
||||
)
|
||||
|
||||
@app.post("/v1/attachments/inspect-pdf")
|
||||
def attachments_inspect_pdf(body: dict) -> dict[str, Any]:
|
||||
# Attach-time page/size probe for the composer's threshold check. Local only.
|
||||
@@ -1551,15 +1629,17 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
|
||||
async def question_asker(args: dict, tool_call_id=None) -> dict:
|
||||
# ask_user (engine does NOT emit the event — we do, only when attended).
|
||||
from ..tools.ask import answer_result, question_item_fields
|
||||
|
||||
fields = question_item_fields(args)
|
||||
if fields is None: # engine guards too; belt-and-braces
|
||||
return {"answer": "", "error": "no question"}
|
||||
item = manager.inbox.add_question(
|
||||
session_id,
|
||||
str(args.get("question", "")),
|
||||
inbox=_route(),
|
||||
visibility=_visibility(),
|
||||
options=list(args.get("options") or []),
|
||||
allow_text=bool(args.get("allow_text", True)),
|
||||
multi=bool(args.get("multi", False)),
|
||||
tool_call_id=tool_call_id,
|
||||
**fields,
|
||||
)
|
||||
if item.state == "pending":
|
||||
manager.persist_session(session_id)
|
||||
@@ -1574,11 +1654,12 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
"options": item.options,
|
||||
"allow_text": item.allow_text,
|
||||
"multi": item.multi,
|
||||
"header": str(args.get("header", "")),
|
||||
"header": item.header,
|
||||
"questions": item.questions,
|
||||
},
|
||||
}
|
||||
)
|
||||
return {"answer": await manager.inbox.wait(item.id)}
|
||||
return answer_result(item.questions, await manager.inbox.wait(item.id))
|
||||
|
||||
async def directory_requester(args: dict, tool_call_id=None) -> dict:
|
||||
# The engine has already emitted DIRECTORY_REQUESTED. Park, await, then apply the grant.
|
||||
@@ -1703,6 +1784,9 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
)
|
||||
await ws.close()
|
||||
return
|
||||
# Auto-compaction failure prompt (OPE-27): only an ATTENDED session may be asked
|
||||
# Retry/Trim — unattended runs auto-trim (the policy in engine._compact_now).
|
||||
engine.is_attended = lambda: _visibility() == VIS_INLINE
|
||||
await ws.send_json(
|
||||
{
|
||||
"type": "ready",
|
||||
@@ -1736,11 +1820,15 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
"iteration_end",
|
||||
}
|
||||
|
||||
async def run_turn(content, *, retry: bool = False) -> None:
|
||||
async def run_turn(content, *, retry: bool = False, display=None) -> None:
|
||||
# The receive loop atomically claims this session before scheduling the task.
|
||||
# Keeping the claim outside prevents two back-to-back frames from both starting.
|
||||
try:
|
||||
events = engine.retry() if retry else engine.run(content)
|
||||
events = (
|
||||
engine.retry()
|
||||
if retry
|
||||
else engine.run(content, display=display)
|
||||
)
|
||||
async for event in events:
|
||||
# Broadcast to every socket viewing this session (this socket included — it's a
|
||||
# registered client), so a second view of the same session stays in sync too.
|
||||
@@ -1766,13 +1854,13 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
# or flush an in-progress assistant stream in the GUI.
|
||||
await ws.send_json({"type": "input_rejected", "data": {"error": reason}})
|
||||
|
||||
async def claim_turn(*, retry: bool = False, content=None) -> None:
|
||||
async def claim_turn(*, retry: bool = False, content=None, display=None) -> None:
|
||||
if not manager.try_mark_running(session_id):
|
||||
await reject_input(
|
||||
"This session is already running a turn. Wait for it to finish or stop it."
|
||||
)
|
||||
return
|
||||
asyncio.create_task(run_turn(content, retry=retry))
|
||||
asyncio.create_task(run_turn(content, retry=retry, display=display))
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -1925,10 +2013,34 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
if model is not None and not isinstance(model, str):
|
||||
await reject_input("Invalid model: expected a string.")
|
||||
continue
|
||||
# Force-run (SKILLS-SPEC §4.1 #3): the composer's `/skill` pick rides as a
|
||||
# separate field. Validated against the session's effective menu — a muted
|
||||
# or unknown skill is a visible error, never a silent no-op (§4.6 #15).
|
||||
# The model-facing framing goes into `content`; the transcript shows the
|
||||
# user's literal "/name …" line via the `_display` sidecar (one bubble).
|
||||
skill = message.get("skill")
|
||||
display = None
|
||||
if skill is not None:
|
||||
if not isinstance(skill, str) or not skill.strip():
|
||||
await reject_input("Invalid skill: expected a name.")
|
||||
continue
|
||||
skill = skill.strip()
|
||||
menu = manager.effective_skill_names(session_id, workspace)
|
||||
if skill not in menu:
|
||||
await reject_input(
|
||||
f"Skill '{skill}' is not available in this session."
|
||||
)
|
||||
continue
|
||||
display = f"/{skill}" + (f" {text}" if text else "")
|
||||
text = (
|
||||
f'Use the skill "{skill}" for this request: first call '
|
||||
f'load_skill("{skill}") and follow its instructions.'
|
||||
+ (f"\n\n{text}" if text else "")
|
||||
)
|
||||
await _apply_model(model)
|
||||
if text or attachments:
|
||||
content = build_user_content(text, attachments)
|
||||
await claim_turn(content=content)
|
||||
await claim_turn(content=content, display=display)
|
||||
else:
|
||||
await reject_input(f"Unknown WebSocket message type: {kind}.")
|
||||
except WebSocketDisconnect:
|
||||
|
||||
+362
-46
@@ -82,7 +82,12 @@ from ..providers import (
|
||||
)
|
||||
from ..secrets import SecretStore, state_dir
|
||||
from ..sessions import SessionRecord
|
||||
from ..skills import SkillLoader
|
||||
from ..skills import (
|
||||
SessionSkillStore,
|
||||
SkillLoader,
|
||||
SkillStore,
|
||||
effective_skills,
|
||||
)
|
||||
|
||||
_SCOPES = {s.value for s in Scope}
|
||||
|
||||
@@ -228,6 +233,11 @@ class SessionManager:
|
||||
self.session_connections = SessionConnectionStore(
|
||||
base / "session_connections.json"
|
||||
)
|
||||
# Skills (SKILLS-SPEC §4): folder-backed CRUD + per-session mutes. The effective menu
|
||||
# gates the engine's skill catalog the same way effective_connectors gates connector
|
||||
# tools — one resolver feeds the catalog injection, the rail, and the composer popup.
|
||||
self.skill_store = SkillStore()
|
||||
self.session_skills = SessionSkillStore(base / "session_skills.json")
|
||||
# Dead-letter: inbound messages with no destination + background-turn failures, so neither
|
||||
# vanishes silently (a debugging/visibility surface, not a redelivery queue).
|
||||
self.unrouted = UnroutedStore(base / "unrouted.json")
|
||||
@@ -279,6 +289,14 @@ class SessionManager:
|
||||
"required": bool(commands and not trusted),
|
||||
}
|
||||
|
||||
def _mcp_workspace_trusted(self, workspace: Optional[str | Path]) -> bool:
|
||||
"""Whether workspace `.coworker/mcp.json` may be loaded (#213).
|
||||
|
||||
Same consent boundary as repository ``allowed_commands``: an untrusted
|
||||
clone must not define stdio processes that spawn at session open.
|
||||
"""
|
||||
return bool(workspace and self.workspace_trust.is_trusted(workspace))
|
||||
|
||||
def set_workspace_trust(
|
||||
self, path: str | Path, *, trusted: bool
|
||||
) -> dict[str, Any]:
|
||||
@@ -460,6 +478,9 @@ class SessionManager:
|
||||
routing_targets=self._routing_targets(session_id, agent),
|
||||
# Per-session connection hierarchy: expose only effective-enabled connectors' tools.
|
||||
connector_filter=self.effective_connectors(session_id, agent_name),
|
||||
# Per-session skill menu, LIVE (SKILLS-SPEC §3): a callable so load_skill sees
|
||||
# disables/new skills immediately; the catalog snapshot is taken at build.
|
||||
skill_filter=lambda sid=session_id, w=ws: self.effective_skill_names(sid, w),
|
||||
)
|
||||
# An automation run rebuilt here (manual "Run now" over WS, durable resume) still
|
||||
# carries its task's standing allowances — the rules live on the task record.
|
||||
@@ -474,6 +495,13 @@ class SessionManager:
|
||||
)
|
||||
if record is not None and record.grants:
|
||||
self._apply_grants(engine, record.grants)
|
||||
# Auto-compaction (OPE-27): restore the persisted view boundary and wire the live
|
||||
# Settings getter — post-construction, so build_engine's signature stays put.
|
||||
if record is not None and record.compaction:
|
||||
from ..compaction import CompactionState
|
||||
|
||||
engine.compaction_state = CompactionState.from_dict(record.compaction)
|
||||
engine.compaction_settings = self.compaction_settings
|
||||
self._engines[session_id] = engine
|
||||
if is_new_session:
|
||||
self._emit_session_created(session_id, agent_name)
|
||||
@@ -737,27 +765,26 @@ class SessionManager:
|
||||
async def ask(
|
||||
args: dict[str, Any], tool_call_id: Optional[str] = None
|
||||
) -> dict[str, Any]:
|
||||
question = str(args.get("question", "")).strip()
|
||||
if not question:
|
||||
from ..tools.ask import answer_result, question_item_fields
|
||||
|
||||
fields = question_item_fields(args)
|
||||
if fields is None:
|
||||
return {"answer": "", "error": "no question"}
|
||||
inbox_name = self.inbox_routing.route_for(session_id, agent)
|
||||
item = self.inbox.add_question(
|
||||
session_id,
|
||||
title=question,
|
||||
inbox=inbox_name,
|
||||
options=list(args.get("options") or []),
|
||||
allow_text=bool(args.get("allow_text", True)),
|
||||
multi=bool(args.get("multi", False)),
|
||||
tool_call_id=tool_call_id,
|
||||
**fields,
|
||||
)
|
||||
if (
|
||||
item.state != "pending"
|
||||
): # durable resume re-raised an already-answered prompt
|
||||
return {"answer": item.resolution or ""}
|
||||
return answer_result(item.questions, item.resolution)
|
||||
self.persist_session(session_id) # the pending tool call is now on disk
|
||||
await self.mirror_inbox_item(item)
|
||||
answer = await self.inbox.wait(item.id)
|
||||
return {"answer": answer}
|
||||
return answer_result(item.questions, answer)
|
||||
|
||||
return ask
|
||||
|
||||
@@ -895,7 +922,11 @@ class SessionManager:
|
||||
loop = asyncio.get_running_loop()
|
||||
effective: Optional[set[str]] = None # computed lazily, once
|
||||
out: list[Any] = []
|
||||
for server in load_mcp_servers(ws, secrets=self.secrets):
|
||||
for server in load_mcp_servers(
|
||||
ws,
|
||||
secrets=self.secrets,
|
||||
workspace_trusted=self._mcp_workspace_trusted(ws),
|
||||
):
|
||||
if not server.enabled:
|
||||
continue
|
||||
if server.auth == "oauth" and not mcp_oauth.has_tokens(
|
||||
@@ -1011,7 +1042,11 @@ class SessionManager:
|
||||
"""Connect one server NOW — for OAuth servers this may open the browser and wait
|
||||
for the loopback callback, so callers run it as a background task and watch
|
||||
list_mcp for the status flip."""
|
||||
for server in load_mcp_servers(self.default_workspace, secrets=self.secrets):
|
||||
for server in load_mcp_servers(
|
||||
self.default_workspace,
|
||||
secrets=self.secrets,
|
||||
workspace_trusted=self._mcp_workspace_trusted(self.default_workspace),
|
||||
):
|
||||
if server.name != name:
|
||||
continue
|
||||
self._mcp_authorizing.add(name)
|
||||
@@ -1089,7 +1124,11 @@ class SessionManager:
|
||||
|
||||
async def mcp_tools(self, name: str) -> dict[str, Any]:
|
||||
"""Connect one server and list its tools (name + description)."""
|
||||
for server in load_mcp_servers(self.default_workspace, secrets=self.secrets):
|
||||
for server in load_mcp_servers(
|
||||
self.default_workspace,
|
||||
secrets=self.secrets,
|
||||
workspace_trusted=self._mcp_workspace_trusted(self.default_workspace),
|
||||
):
|
||||
if server.name == name:
|
||||
try:
|
||||
conn = await self.mcp.ensure(server)
|
||||
@@ -1237,39 +1276,47 @@ class SessionManager:
|
||||
".doc",
|
||||
".docm",
|
||||
}
|
||||
for path in root.rglob("*"):
|
||||
try:
|
||||
rel = path.relative_to(root)
|
||||
if any(
|
||||
part.startswith(".")
|
||||
or part in {"node_modules", "target", "dist", "__pycache__"}
|
||||
for part in rel.parts
|
||||
):
|
||||
# os.walk with in-place pruning, NOT rglob: rglob descends first and filters after,
|
||||
# so a home-directory workspace walked into ~/Library and tripped the macOS App Data
|
||||
# TCC prompt ("OpenWorker would like to access data from other apps") on every turn.
|
||||
# Pruning here means those directories are never entered at all.
|
||||
from ..tools.search import OS_DATA_DIRS
|
||||
|
||||
skip = {"node_modules", "target", "dist", "__pycache__"} | OS_DATA_DIRS
|
||||
for dirpath, dirs, files in os.walk(root):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".") and d not in skip]
|
||||
for name in files:
|
||||
if name.startswith("."):
|
||||
continue
|
||||
if not path.is_file() or path.suffix.lower() not in suffixes:
|
||||
path = Path(dirpath) / name
|
||||
if path.suffix.lower() not in suffixes:
|
||||
continue
|
||||
try:
|
||||
st = path.stat()
|
||||
if not path.is_file():
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"path": str(path.relative_to(root)),
|
||||
# Absolute path for "Copy path" — the relative one is useless
|
||||
# outside the app (tester catch 2026-07-12: it copied just the
|
||||
# filename).
|
||||
"abs_path": str(path),
|
||||
"name": path.name,
|
||||
"kind": _artifact_kind(path),
|
||||
"size": st.st_size,
|
||||
"modified_at": st.st_mtime,
|
||||
}
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
st = path.stat()
|
||||
out.append(
|
||||
{
|
||||
"path": str(rel),
|
||||
# Absolute path for "Copy path" — the relative one is useless outside
|
||||
# the app (tester catch 2026-07-12: it copied just the filename).
|
||||
"abs_path": str(path),
|
||||
"name": path.name,
|
||||
"kind": _artifact_kind(path),
|
||||
"size": st.st_size,
|
||||
"modified_at": st.st_mtime,
|
||||
}
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
out.sort(key=lambda a: a["modified_at"], reverse=True)
|
||||
return out[:80]
|
||||
|
||||
MAX_BINARY_PREVIEW = 25 * 1024 * 1024 # base64-over-JSON gets heavy past this
|
||||
|
||||
def _artifact_target(
|
||||
self, session_id: str, path: str
|
||||
self, session_id: str, path: str, *, allow_dir: bool = False
|
||||
) -> tuple[Optional[Path], Optional[str]]:
|
||||
"""Resolve an artifact path under the session's workspace, or (None, error)."""
|
||||
record = self.session_store.load(session_id)
|
||||
@@ -1282,14 +1329,36 @@ class SessionManager:
|
||||
target.relative_to(root)
|
||||
except ValueError:
|
||||
return None, "path escapes workspace"
|
||||
if allow_dir and target.is_dir():
|
||||
return target, None
|
||||
if not target.is_file():
|
||||
return None, "not found"
|
||||
return None, (
|
||||
"This isn't in the conversation's folder anymore — it may have been "
|
||||
"moved or deleted."
|
||||
)
|
||||
return target, None
|
||||
|
||||
def read_artifact(self, session_id: str, path: str) -> dict[str, Any]:
|
||||
target, err = self._artifact_target(session_id, path)
|
||||
# Folders are readable too (a model sometimes links a whole package, e.g. a skill
|
||||
# build dir): return a listing the viewer can render instead of a dead end.
|
||||
target, err = self._artifact_target(session_id, path, allow_dir=True)
|
||||
if target is None:
|
||||
return {"ok": False, "error": err}
|
||||
if target.is_dir():
|
||||
entries: list[dict[str, Any]] = []
|
||||
try:
|
||||
children = sorted(
|
||||
target.iterdir(), key=lambda c: (c.is_file(), c.name.lower())
|
||||
)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
for child in children[:500]:
|
||||
try:
|
||||
size = 0 if child.is_dir() else child.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
entries.append({"name": child.name, "dir": child.is_dir(), "size": size})
|
||||
return {"ok": True, "path": path, "kind": "folder", "entries": entries}
|
||||
kind = _artifact_kind(target)
|
||||
if kind == "office":
|
||||
# PowerPoint/Word binaries can't be previewed inline; the UI offers
|
||||
@@ -1343,27 +1412,29 @@ class SessionManager:
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
target, err = self._artifact_target(session_id, path)
|
||||
target, err = self._artifact_target(session_id, path, allow_dir=True)
|
||||
if target is None:
|
||||
return {"ok": False, "error": err}
|
||||
# A folder "opens" as itself in the file manager, whatever the mode.
|
||||
is_dir = target.is_dir()
|
||||
try:
|
||||
if sys.platform == "darwin":
|
||||
args = (
|
||||
["open", "-R", str(target)]
|
||||
if mode == "reveal"
|
||||
if mode == "reveal" and not is_dir
|
||||
else ["open", str(target)]
|
||||
)
|
||||
subprocess.Popen(
|
||||
args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
elif sys.platform == "win32":
|
||||
if mode == "reveal":
|
||||
if mode == "reveal" and not is_dir:
|
||||
# Explorer wants the path glued to the switch: /select,<path>
|
||||
subprocess.Popen(["explorer", f"/select,{target}"])
|
||||
else:
|
||||
os.startfile(str(target)) # type: ignore[attr-defined] # open in default app
|
||||
else: # Linux/BSD
|
||||
tgt = str(target.parent) if mode == "reveal" else str(target)
|
||||
tgt = str(target.parent) if mode == "reveal" and not is_dir else str(target)
|
||||
subprocess.Popen(
|
||||
["xdg-open", tgt],
|
||||
stdout=subprocess.DEVNULL,
|
||||
@@ -1792,12 +1863,14 @@ class SessionManager:
|
||||
"surfaces": self._surfaces(),
|
||||
"nav_layout": self._nav_layout(),
|
||||
"sessions_peek": self.sessions_peek(),
|
||||
"context_bar": self.context_bar(),
|
||||
"scratch_base": self._prefs.get("scratch_base")
|
||||
or self.DEFAULT_SCRATCH_BASE,
|
||||
# Real on-disk secrets location, so the UI shows the OS-native path instead of a
|
||||
# hardcoded POSIX one (Windows -> %APPDATA%\coworker, macOS/Linux -> ~/.config).
|
||||
"secrets_path": str(self.secrets.path),
|
||||
**self.pdf_settings(),
|
||||
**self.compaction_settings_payload(),
|
||||
}
|
||||
|
||||
def _surfaces(self) -> dict[str, bool]:
|
||||
@@ -1850,6 +1923,16 @@ class SessionManager:
|
||||
self._save_prefs()
|
||||
return {"ok": True, "sessions_peek": self.sessions_peek()}
|
||||
|
||||
def context_bar(self) -> bool:
|
||||
"""Whether the composer shows the context-window fill bar. OFF by default (owner
|
||||
ask): the chip then states the session total, and the popover keeps both numbers."""
|
||||
return bool(self._prefs.get("context_bar", False))
|
||||
|
||||
def set_context_bar(self, shown: Any) -> dict[str, Any]:
|
||||
self._prefs["context_bar"] = bool(shown)
|
||||
self._save_prefs()
|
||||
return {"ok": True, "context_bar": self.context_bar()}
|
||||
|
||||
# -- PDF attachments / token savings (owner ask, 2026-07-17) ----------------
|
||||
DEFAULT_PDF_MAX_PAGES = 20
|
||||
DEFAULT_PDF_MAX_MB = 10
|
||||
@@ -1874,6 +1957,65 @@ class SessionManager:
|
||||
"pdf_max_mb": max(1, min(mb, 10)),
|
||||
}
|
||||
|
||||
def compaction_settings(self) -> dict[str, Any]:
|
||||
"""The live auto-compaction knobs (OPE-27) — read by every engine per check, so a
|
||||
Settings change applies without a rebuild. Only the two spec'd overrides plus the
|
||||
summarizer-model pin; absent keys fall back to compaction.py defaults."""
|
||||
from ..compaction import DEFAULT_CAP_TOKENS, DEFAULT_THRESHOLD_PCT
|
||||
|
||||
return {
|
||||
"threshold_pct": float(
|
||||
self._prefs.get("compaction_threshold_pct") or DEFAULT_THRESHOLD_PCT
|
||||
),
|
||||
"cap_tokens": int(
|
||||
self._prefs.get("compaction_cap_tokens") or DEFAULT_CAP_TOKENS
|
||||
),
|
||||
# "" → the session's own model (engine falls back to self.model).
|
||||
"model": str(self._prefs.get("compaction_model") or ""),
|
||||
}
|
||||
|
||||
def compaction_settings_payload(self) -> dict[str, Any]:
|
||||
"""The same knobs under REST-facing names (prefixed to keep /v1/settings flat)."""
|
||||
settings = self.compaction_settings()
|
||||
return {
|
||||
"compaction_threshold_pct": settings["threshold_pct"],
|
||||
"compaction_cap_tokens": settings["cap_tokens"],
|
||||
"compaction_model": settings["model"],
|
||||
}
|
||||
|
||||
def set_compaction_settings(
|
||||
self,
|
||||
threshold_pct: Any = None,
|
||||
cap_tokens: Any = None,
|
||||
model: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist the auto-compaction overrides (OPE-27). Threshold is a percentage of
|
||||
the model's context window (10–95); the cap is an absolute token ceiling; model
|
||||
pins the summarizer ('' → the session's own model). Engines read these live via
|
||||
`compaction_settings()`, so changes apply to running sessions immediately."""
|
||||
if threshold_pct is not None:
|
||||
try:
|
||||
pct = float(threshold_pct)
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "error": "compaction_threshold_pct must be a number"}
|
||||
if not 0.10 <= pct <= 0.95:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "compaction_threshold_pct must be between 0.10 and 0.95",
|
||||
}
|
||||
self._prefs["compaction_threshold_pct"] = pct
|
||||
if cap_tokens is not None:
|
||||
try:
|
||||
self._prefs["compaction_cap_tokens"] = max(
|
||||
10_000, min(int(cap_tokens), 2_000_000)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "error": "compaction_cap_tokens must be a number"}
|
||||
if model is not None:
|
||||
self._prefs["compaction_model"] = str(model)
|
||||
self._save_prefs()
|
||||
return {"ok": True, **self.compaction_settings()}
|
||||
|
||||
def set_pdf_settings(
|
||||
self,
|
||||
fallback: Any = None,
|
||||
@@ -2614,6 +2756,9 @@ class SessionManager:
|
||||
# Scheduled runs respect the same per-session connection hierarchy as live sessions:
|
||||
# expose only the persona's effective-enabled connectors' tools (§4.3).
|
||||
connector_filter=self.effective_connectors(session_id, task.agent),
|
||||
skill_filter=lambda sid=session_id, w=task.workspace: (
|
||||
self.effective_skill_names(sid, w)
|
||||
),
|
||||
)
|
||||
self._seed_task_permissions(engine, task)
|
||||
return engine
|
||||
@@ -3269,6 +3414,11 @@ class SessionManager:
|
||||
agent=getattr(engine, "agent_name", "code"),
|
||||
extra_roots=self._extra_roots_of(engine),
|
||||
grants=_grants_of(engine),
|
||||
compaction=(
|
||||
engine.compaction_state.as_dict()
|
||||
if getattr(engine, "compaction_state", None)
|
||||
else {}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3592,6 +3742,8 @@ class SessionManager:
|
||||
self.mention_sessions.remove_session(session_id)
|
||||
# ...and drops its per-session connector overrides (§4.2, like subscriptions).
|
||||
self.session_connections.remove_session(session_id)
|
||||
# ...and its per-session skill mutes (SKILLS-SPEC §3 — mutes die with the session).
|
||||
self.session_skills.remove_session(session_id)
|
||||
# ...and closes its pending Inbox items — an orphaned approval/question can never be
|
||||
# meaningfully answered (owner call, 2026-07-03).
|
||||
self.inbox.resolve_session(session_id)
|
||||
@@ -3667,9 +3819,173 @@ class SessionManager:
|
||||
def list_agents(self) -> list[dict[str, Any]]:
|
||||
return _list_agents()
|
||||
|
||||
def list_skills(self) -> list[dict[str, Any]]:
|
||||
loader = SkillLoader([state_dir() / "skills"])
|
||||
return loader.catalog()
|
||||
# -- skills (SKILLS-SPEC §4.4) ------------------------------------------------
|
||||
def list_skills(self, workspace: Optional[str] = None) -> list[dict[str, Any]]:
|
||||
"""Enriched rows for the Settings screen (scope/source/enabled). Optional workspace
|
||||
adds that project's skills, with project copies shadowing same-named global ones."""
|
||||
return self.skill_store.rows(workspace or None)
|
||||
|
||||
def reveal_skill(
|
||||
self, name: str, workspace: Optional[str] = None
|
||||
) -> dict[str, Any]:
|
||||
"""Open the skill's folder in the OS file manager (§6 "Show folder" — the power-user
|
||||
window into folder-is-truth). Same local-machine rationale as reveal_artifact."""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
try:
|
||||
folder, _scope = self.skill_store.find(name, workspace or None)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
try:
|
||||
if sys.platform == "darwin":
|
||||
subprocess.Popen(
|
||||
["open", str(folder)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
elif sys.platform == "win32":
|
||||
import os
|
||||
|
||||
os.startfile(str(folder)) # type: ignore[attr-defined]
|
||||
else:
|
||||
subprocess.Popen(
|
||||
["xdg-open", str(folder)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
return {"ok": True}
|
||||
|
||||
def effective_skill_names(
|
||||
self, session_id: str, workspace: Optional[str | Path] = None
|
||||
) -> set[str]:
|
||||
"""The session's skill menu (§3): merged scopes − Settings disables − session mutes.
|
||||
The single resolver behind the engine catalog, the rail list, and the composer popup."""
|
||||
dirs = [self.skill_store.global_dir]
|
||||
if workspace:
|
||||
dirs.append(self.skill_store.project_dir(workspace))
|
||||
loader = SkillLoader(dirs)
|
||||
return effective_skills(
|
||||
names=set(loader.names()),
|
||||
disabled=self.skill_store.disabled_names(),
|
||||
session_overrides=self.session_skills.get(session_id),
|
||||
)
|
||||
|
||||
def session_skills_view(
|
||||
self, session_id: str, workspace: Optional[str] = None
|
||||
) -> dict[str, Any]:
|
||||
"""The rail payload: every in-scope, Settings-enabled skill with its mute state."""
|
||||
disabled = self.skill_store.disabled_names()
|
||||
overrides = self.session_skills.get(session_id)
|
||||
rows = [
|
||||
{
|
||||
"name": r["name"],
|
||||
"description": r["description"],
|
||||
"scope": r["scope"],
|
||||
"enabled": overrides.get(r["name"], True),
|
||||
}
|
||||
for r in self.skill_store.rows(workspace or None)
|
||||
if r["name"] not in disabled
|
||||
]
|
||||
return {"skills": rows}
|
||||
|
||||
def _scratch_workspace_error(self, workspace: Any) -> Optional[dict[str, Any]]:
|
||||
"""Refuse skill WRITES into a per-conversation scratch dir — a skill saved there is
|
||||
stranded in a throwaway folder. Backend chokepoint: guards every entry path (UI,
|
||||
REST, future import), not just the flows the GUI happens to gate."""
|
||||
if not workspace:
|
||||
return None
|
||||
try:
|
||||
ws = Path(str(workspace)).expanduser().resolve()
|
||||
if ws.is_relative_to(self.scratch_base().resolve()):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": (
|
||||
"That folder is a temporary session space — skills saved there "
|
||||
"would be lost. Save it globally or pick a real project."
|
||||
),
|
||||
}
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def create_skill(self, body: dict[str, Any]) -> dict[str, Any]:
|
||||
blocked = self._scratch_workspace_error(body.get("workspace"))
|
||||
if blocked:
|
||||
return blocked
|
||||
try:
|
||||
created = self.skill_store.create(
|
||||
name=str(body.get("name", "")),
|
||||
description=str(body.get("description", "")),
|
||||
instructions=str(body.get("instructions", "")),
|
||||
scope=str(body.get("scope", "global") or "global"),
|
||||
workspace=body.get("workspace") or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
return {"ok": True, "skill": created}
|
||||
|
||||
def update_skill(self, name: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
if "enabled" in body:
|
||||
self.skill_store.set_enabled(name, bool(body["enabled"]))
|
||||
if body.get("description") is not None or body.get("instructions") is not None:
|
||||
self.skill_store.update(
|
||||
name,
|
||||
description=body.get("description"),
|
||||
instructions=body.get("instructions"),
|
||||
workspace=body.get("workspace") or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
return {"ok": True}
|
||||
|
||||
def delete_skill(self, name: str, workspace: Optional[str] = None) -> dict[str, Any]:
|
||||
try:
|
||||
self.skill_store.delete(name, workspace or None)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
return {"ok": True}
|
||||
|
||||
def move_skill(self, name: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
# Moving INTO project scope must not target a scratch dir (moving OUT is fine —
|
||||
# that's the rescue path for already-stranded skills).
|
||||
if str(body.get("scope", "")) == "project":
|
||||
blocked = self._scratch_workspace_error(body.get("workspace"))
|
||||
if blocked:
|
||||
return blocked
|
||||
try:
|
||||
moved = self.skill_store.move(
|
||||
name,
|
||||
to_scope=str(body.get("scope", "")),
|
||||
workspace=body.get("workspace") or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
return {"ok": True, "skill": moved}
|
||||
|
||||
def stage_skill_upload(self, data: bytes, filename: str = "") -> dict[str, Any]:
|
||||
try:
|
||||
preview = self.skill_store.stage_upload(data, filename)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
return {"ok": True, **preview}
|
||||
|
||||
def confirm_skill_upload(self, body: dict[str, Any]) -> dict[str, Any]:
|
||||
blocked = self._scratch_workspace_error(body.get("workspace"))
|
||||
if blocked:
|
||||
return blocked
|
||||
try:
|
||||
saved = self.skill_store.confirm_upload(
|
||||
str(body.get("token", "")),
|
||||
scope=str(body.get("scope", "global") or "global"),
|
||||
workspace=body.get("workspace") or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
return {"ok": True, "skill": saved}
|
||||
|
||||
def _memory_saved_notifier(self, session_id: str):
|
||||
"""MEMORY-SPEC §5.1: push the memory_saved event that powers the GUI's save
|
||||
|
||||
@@ -34,3 +34,6 @@ class SessionRecord:
|
||||
# (e.g. origin="slack", origin_label="#general · T0ABCD"). Set once at spawn.
|
||||
origin: Optional[str] = None
|
||||
origin_label: Optional[str] = None
|
||||
# Auto-compaction state (OPE-27): CompactionState.as_dict(), {} when never compacted.
|
||||
# Persisted so a reloaded session keeps its compacted outbound view.
|
||||
compaction: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
from .base import Skill, SkillLoader, skill_catalog_text, skill_tools
|
||||
from .store import (
|
||||
SessionSkillStore,
|
||||
SkillStore,
|
||||
effective_skills,
|
||||
save_skill_tool,
|
||||
validate_name,
|
||||
)
|
||||
|
||||
__all__ = ["Skill", "SkillLoader", "skill_catalog_text", "skill_tools"]
|
||||
__all__ = [
|
||||
"Skill",
|
||||
"SkillLoader",
|
||||
"skill_catalog_text",
|
||||
"skill_tools",
|
||||
"SkillStore",
|
||||
"SessionSkillStore",
|
||||
"effective_skills",
|
||||
"save_skill_tool",
|
||||
"validate_name",
|
||||
]
|
||||
|
||||
+37
-7
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Callable, Optional, Union
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
@@ -27,9 +27,17 @@ class Skill:
|
||||
|
||||
class SkillLoader:
|
||||
def __init__(self, dirs: list[str | Path]) -> None:
|
||||
self._dirs = [Path(d) for d in dirs]
|
||||
self._skills: dict[str, Skill] = {}
|
||||
for directory in dirs:
|
||||
self._discover(Path(directory))
|
||||
self.rescan()
|
||||
|
||||
def rescan(self) -> None:
|
||||
"""Re-read the skill dirs. load_skill rescans on a miss so a skill created AFTER
|
||||
the session's engine was built is still loadable (the catalog line stays static
|
||||
until the next session, but an explicitly requested skill must not 404)."""
|
||||
self._skills = {}
|
||||
for directory in self._dirs:
|
||||
self._discover(directory)
|
||||
|
||||
def _discover(self, directory: Path) -> None:
|
||||
if not directory.is_dir():
|
||||
@@ -81,8 +89,12 @@ def _parse_skill(md: Path) -> Skill:
|
||||
)
|
||||
|
||||
|
||||
def skill_catalog_text(loader: SkillLoader) -> str:
|
||||
catalog = loader.catalog()
|
||||
def skill_catalog_text(
|
||||
loader: SkillLoader, allowed: Optional[set[str]] = None
|
||||
) -> str:
|
||||
catalog = [
|
||||
c for c in loader.catalog() if allowed is None or c["name"] in allowed
|
||||
]
|
||||
if not catalog:
|
||||
return ""
|
||||
lines = [f"- {c['name']}: {c['description']}" for c in catalog]
|
||||
@@ -92,13 +104,31 @@ def skill_catalog_text(loader: SkillLoader) -> str:
|
||||
)
|
||||
|
||||
|
||||
def skill_tools(loader: SkillLoader) -> list:
|
||||
AllowedSkills = Union[set, Callable[[], set], None]
|
||||
|
||||
|
||||
def skill_tools(loader: SkillLoader, allowed: AllowedSkills = None) -> list:
|
||||
"""`allowed` gates load_skill: a set is a build-time snapshot; a CALLABLE is consulted
|
||||
on every call — the manager passes one so Settings disables apply to live sessions
|
||||
immediately, and skills created after the engine was built are still loadable
|
||||
(loader rescans on a miss)."""
|
||||
|
||||
def _allowed_now() -> Optional[set]:
|
||||
return allowed() if callable(allowed) else allowed
|
||||
|
||||
def load_skill(name: str) -> dict:
|
||||
"""Load a skill's full instructions + resources path by name. Call this when a
|
||||
skill from the catalog is relevant to the current task."""
|
||||
skill = loader.get(name)
|
||||
if skill is None:
|
||||
return {"error": f"unknown skill: {name}", "available": loader.names()}
|
||||
loader.rescan() # created after this session started? pick it up now
|
||||
skill = loader.get(name)
|
||||
gate = _allowed_now()
|
||||
if skill is None or (gate is not None and name not in gate):
|
||||
available = sorted(
|
||||
n for n in loader.names() if gate is None or n in gate
|
||||
)
|
||||
return {"error": f"unknown skill: {name}", "available": available}
|
||||
return {
|
||||
"name": skill.name,
|
||||
"instructions": skill.instructions,
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
"""Skill management — CRUD over skill folders + per-session mutes (SKILLS-SPEC §4).
|
||||
|
||||
Scope = folder location (folder-is-truth): global skills live in ``state_dir()/skills``,
|
||||
project skills in ``<workspace>/.coworker/skills``. There is no database; every operation
|
||||
is a folder + ``SKILL.md`` operation, which keeps project skills shareable via git for free.
|
||||
|
||||
Disable state is deliberately NOT a marker inside the skill folder: project folders travel
|
||||
with the repo and one user's disable must not be committed to teammates. It lives in the
|
||||
personal ``state_dir()/skills-settings.json`` instead.
|
||||
|
||||
Uploads are staged (parse → preview → confirm) so the user always reviews exactly what will
|
||||
be saved before anything lands in a scope dir. Staged content sits under
|
||||
``state_dir()/skills-staged/<token>`` until confirmed or discarded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
from ..secrets import state_dir
|
||||
from .base import Skill, _parse_skill
|
||||
|
||||
_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
_MAX_NAME = 64
|
||||
GLOBAL_SCOPE = "global"
|
||||
PROJECT_SCOPE = "project"
|
||||
|
||||
|
||||
def validate_name(name: str) -> str:
|
||||
"""Skill names become folder names — reject anything that could escape the scope dir."""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Skill name is required.")
|
||||
if len(name) > _MAX_NAME:
|
||||
raise ValueError(f"Skill name too long (limit {_MAX_NAME} characters).")
|
||||
if ".." in name or "/" in name or "\\" in name or not _NAME_RE.match(name):
|
||||
raise ValueError(
|
||||
"Skill name may only contain letters, digits, dots, dashes, and underscores."
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _frontmatter_source(md: Path) -> str:
|
||||
"""Read the optional ``source:`` frontmatter key (``uploaded`` etc.). Absent → created here."""
|
||||
try:
|
||||
text = md.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return ""
|
||||
if not text.startswith("---"):
|
||||
return ""
|
||||
end = text.find("\n---", 3)
|
||||
if end == -1:
|
||||
return ""
|
||||
for line in text[3:end].splitlines():
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
if key.strip().lower() == "source":
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _write_skill_md(
|
||||
folder: Path, *, name: str, description: str, instructions: str, source: str = ""
|
||||
) -> None:
|
||||
lines = ["---", f"name: {name}", f"description: {description}"]
|
||||
if source:
|
||||
lines.append(f"source: {source}")
|
||||
lines += ["---", "", instructions.strip(), ""]
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / "SKILL.md").write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
class SkillStore:
|
||||
"""Folder-backed skill CRUD across the global + project scopes."""
|
||||
|
||||
def __init__(self, global_dir: Optional[str | Path] = None) -> None:
|
||||
self.global_dir = Path(global_dir) if global_dir else state_dir() / "skills"
|
||||
self._settings_path = state_dir() / "skills-settings.json"
|
||||
self._staging_dir = state_dir() / "skills-staged"
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# -- scope dirs ---------------------------------------------------------------
|
||||
def project_dir(self, workspace: str | Path) -> Path:
|
||||
return Path(workspace).expanduser().resolve() / ".coworker" / "skills"
|
||||
|
||||
def _base(self, scope: str, workspace: Optional[str | Path]) -> Path:
|
||||
if scope == GLOBAL_SCOPE:
|
||||
return self.global_dir
|
||||
if scope == PROJECT_SCOPE:
|
||||
if not workspace:
|
||||
raise ValueError("A workspace is required for a project-scoped skill.")
|
||||
ws = Path(workspace).expanduser()
|
||||
if not ws.is_dir():
|
||||
raise ValueError(f"Unknown workspace: {workspace}")
|
||||
return self.project_dir(ws)
|
||||
raise ValueError(f"Unknown scope: {scope}")
|
||||
|
||||
def _folder_of(self, base: Path, name: str) -> Path:
|
||||
"""The skill's folder, guarded against escaping its scope dir (symlinked folders
|
||||
that resolve elsewhere are treated as absent rather than followed)."""
|
||||
folder = base / name
|
||||
try:
|
||||
resolved = folder.resolve()
|
||||
base_resolved = base.resolve()
|
||||
except OSError:
|
||||
raise ValueError(f"Unreadable skill folder: {name}")
|
||||
if base_resolved not in resolved.parents and resolved != base_resolved / name:
|
||||
raise ValueError(f"Skill folder escapes its scope: {name}")
|
||||
return folder
|
||||
|
||||
# -- queries ------------------------------------------------------------------
|
||||
def find(
|
||||
self, name: str, workspace: Optional[str | Path] = None
|
||||
) -> tuple[Path, str]:
|
||||
"""Locate a skill by name, most-local first (project before global) — mirrors the
|
||||
loader's collision precedence so management operates on the copy the model sees."""
|
||||
name = validate_name(name)
|
||||
if workspace:
|
||||
project = self.project_dir(Path(workspace).expanduser())
|
||||
if (project / name / "SKILL.md").is_file():
|
||||
return self._folder_of(project, name), PROJECT_SCOPE
|
||||
if (self.global_dir / name / "SKILL.md").is_file():
|
||||
return self._folder_of(self.global_dir, name), GLOBAL_SCOPE
|
||||
raise ValueError(f"Unknown skill: {name}")
|
||||
|
||||
def rows(self, workspace: Optional[str | Path] = None) -> list[dict[str, Any]]:
|
||||
"""Enriched listing for the Settings screen: scope, source, enabled. Global first,
|
||||
then project (a project row with a colliding name is the effective copy)."""
|
||||
disabled = self.disabled_names()
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: dict[str, int] = {}
|
||||
scopes: list[tuple[Path, str]] = [(self.global_dir, GLOBAL_SCOPE)]
|
||||
if workspace:
|
||||
scopes.append((self.project_dir(Path(workspace).expanduser()), PROJECT_SCOPE))
|
||||
for base, scope in scopes:
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for sub in sorted(base.iterdir()):
|
||||
md = sub / "SKILL.md"
|
||||
if not md.is_file():
|
||||
continue
|
||||
skill = _parse_skill(md)
|
||||
try:
|
||||
# Bundled resources beyond SKILL.md (§6): a rich skill must not look
|
||||
# identical to a one-file one in the Settings list.
|
||||
bundled = sum(1 for p in sub.rglob("*") if p.is_file()) - 1
|
||||
except OSError:
|
||||
bundled = 0
|
||||
row = {
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"instructions": skill.instructions, # Settings editor prefill
|
||||
"scope": scope,
|
||||
"source": _frontmatter_source(md) or "local",
|
||||
"enabled": skill.name not in disabled,
|
||||
"path": str(sub),
|
||||
"files": max(bundled, 0),
|
||||
}
|
||||
if skill.name in seen: # project copy shadows the global one
|
||||
out[seen[skill.name]] = row
|
||||
else:
|
||||
seen[skill.name] = len(out)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
# -- mutations ----------------------------------------------------------------
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
instructions: str,
|
||||
scope: str = GLOBAL_SCOPE,
|
||||
workspace: Optional[str | Path] = None,
|
||||
source: str = "",
|
||||
) -> dict[str, Any]:
|
||||
name = validate_name(name)
|
||||
description = (description or "").strip()
|
||||
if not (instructions or "").strip():
|
||||
raise ValueError("Skill instructions are required.")
|
||||
base = self._base(scope, workspace)
|
||||
folder = self._folder_of(base, name)
|
||||
if (folder / "SKILL.md").is_file():
|
||||
raise ValueError(f"A skill named '{name}' already exists in that scope.")
|
||||
_write_skill_md(
|
||||
folder,
|
||||
name=name,
|
||||
description=description,
|
||||
instructions=instructions,
|
||||
source=source,
|
||||
)
|
||||
return {"name": name, "scope": scope, "path": str(folder)}
|
||||
|
||||
def update(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
description: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
workspace: Optional[str | Path] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Rewrite SKILL.md fields in place; sibling resource files are untouched."""
|
||||
folder, scope = self.find(name, workspace)
|
||||
current = _parse_skill(folder / "SKILL.md")
|
||||
if instructions is not None and not instructions.strip():
|
||||
raise ValueError("Skill instructions are required.")
|
||||
_write_skill_md(
|
||||
folder,
|
||||
name=current.name,
|
||||
description=(
|
||||
description if description is not None else current.description
|
||||
),
|
||||
instructions=(
|
||||
instructions if instructions is not None else current.instructions
|
||||
),
|
||||
source=_frontmatter_source(folder / "SKILL.md"),
|
||||
)
|
||||
return {"name": current.name, "scope": scope}
|
||||
|
||||
def delete(self, name: str, workspace: Optional[str | Path] = None) -> None:
|
||||
folder, _scope = self.find(name, workspace)
|
||||
if folder.is_symlink(): # never follow a link out of the scope dir
|
||||
folder.unlink()
|
||||
return
|
||||
shutil.rmtree(folder)
|
||||
|
||||
def move(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
to_scope: str,
|
||||
workspace: Optional[str | Path] = None,
|
||||
) -> dict[str, Any]:
|
||||
folder, from_scope = self.find(name, workspace)
|
||||
if from_scope == to_scope:
|
||||
return {"name": name, "scope": to_scope}
|
||||
target_base = self._base(to_scope, workspace)
|
||||
target = self._folder_of(target_base, name)
|
||||
if (target / "SKILL.md").is_file():
|
||||
raise ValueError(
|
||||
f"A skill named '{name}' already exists in the target scope."
|
||||
)
|
||||
target_base.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(folder), str(target))
|
||||
return {"name": name, "scope": to_scope}
|
||||
|
||||
# -- enable / disable (personal, survives restarts) -----------------------------
|
||||
def disabled_names(self) -> set[str]:
|
||||
try:
|
||||
data = json.loads(self._settings_path.read_text(encoding="utf-8"))
|
||||
return {str(n) for n in data.get("disabled", [])}
|
||||
except (OSError, ValueError):
|
||||
return set()
|
||||
|
||||
def set_enabled(self, name: str, enabled: bool) -> None:
|
||||
name = validate_name(name)
|
||||
with self._lock:
|
||||
disabled = self.disabled_names()
|
||||
if enabled:
|
||||
disabled.discard(name)
|
||||
else:
|
||||
disabled.add(name)
|
||||
self._settings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._settings_path.write_text(
|
||||
json.dumps({"disabled": sorted(disabled)}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# -- uploads: stage → preview → confirm -----------------------------------------
|
||||
def stage_upload(self, data: bytes, filename: str = "") -> dict[str, Any]:
|
||||
"""Stage an upload and return the parsed preview. Accepts a ``.zip`` (folder skill)
|
||||
or a bare ``SKILL.md`` with YAML frontmatter. Nothing is installed until
|
||||
:meth:`confirm_upload`. (A ``.skill`` file is a renamed zip and still unpacks —
|
||||
just not advertised.)"""
|
||||
try:
|
||||
archive = zipfile.ZipFile(io.BytesIO(data))
|
||||
except zipfile.BadZipFile:
|
||||
return self._stage_single_md(data, filename)
|
||||
# macOS Finder's "Compress" injects __MACOSX/ shadow entries (._*) and .DS_Store —
|
||||
# metadata, not skill content. Strip them so a Mac-made zip installs clean.
|
||||
names = [
|
||||
n
|
||||
for n in archive.namelist()
|
||||
if not n.endswith("/")
|
||||
and "__MACOSX" not in Path(n).parts
|
||||
and Path(n).name != ".DS_Store"
|
||||
and not Path(n).name.startswith("._")
|
||||
]
|
||||
for entry in names:
|
||||
p = Path(entry)
|
||||
if p.is_absolute() or ".." in p.parts or (p.parts and ":" in p.parts[0]):
|
||||
raise ValueError("Archive contains unsafe paths.")
|
||||
# SKILL.md at the root, or inside exactly one top-level folder.
|
||||
md_entries = [n for n in names if Path(n).name == "SKILL.md"]
|
||||
roots = {Path(n).parts[0] if len(Path(n).parts) > 1 else "" for n in md_entries}
|
||||
if not md_entries or len(roots) != 1:
|
||||
raise ValueError("Archive must contain exactly one skill (one SKILL.md).")
|
||||
root = roots.pop()
|
||||
token = uuid.uuid4().hex
|
||||
staged = self._staging_dir / token
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
for entry in names:
|
||||
parts = Path(entry).parts
|
||||
rel = Path(*parts[1:]) if root and parts[0] == root else Path(entry)
|
||||
if not str(rel):
|
||||
continue
|
||||
target = staged / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(archive.read(entry))
|
||||
skill = _parse_skill(staged / "SKILL.md")
|
||||
name = skill.name if skill.name else staged.name
|
||||
try:
|
||||
validate_name(name)
|
||||
except ValueError:
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
raise
|
||||
extras = sorted(
|
||||
str(p.relative_to(staged))
|
||||
for p in staged.rglob("*")
|
||||
if p.is_file() and p.name != "SKILL.md"
|
||||
)
|
||||
return {
|
||||
"token": token,
|
||||
"name": name,
|
||||
"description": skill.description,
|
||||
"instructions": skill.instructions,
|
||||
"files": extras,
|
||||
}
|
||||
|
||||
def _stage_single_md(self, data: bytes, filename: str) -> dict[str, Any]:
|
||||
"""The bare-.md path: one SKILL.md, no resources. Frontmatter must carry the name
|
||||
(there is no folder to fall back to)."""
|
||||
if filename.lower().endswith((".zip", ".skill")):
|
||||
raise ValueError("Not a valid .zip archive.")
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise ValueError("Not a valid skill file — upload a .zip or a SKILL.md.")
|
||||
token = uuid.uuid4().hex
|
||||
staged = self._staging_dir / token
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
(staged / "SKILL.md").write_text(text, encoding="utf-8")
|
||||
skill = _parse_skill(staged / "SKILL.md")
|
||||
if skill.name == token: # no frontmatter name → parser fell back to the folder
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
raise ValueError(
|
||||
"The .md file needs YAML frontmatter with at least a skill name."
|
||||
)
|
||||
try:
|
||||
validate_name(skill.name)
|
||||
except ValueError:
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
raise
|
||||
return {
|
||||
"token": token,
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"instructions": skill.instructions,
|
||||
"files": [],
|
||||
}
|
||||
|
||||
def confirm_upload(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
scope: str = GLOBAL_SCOPE,
|
||||
workspace: Optional[str | Path] = None,
|
||||
) -> dict[str, Any]:
|
||||
staged = self._staging_dir / str(token)
|
||||
if not (staged / "SKILL.md").is_file():
|
||||
raise ValueError("Unknown or expired upload.")
|
||||
skill = _parse_skill(staged / "SKILL.md")
|
||||
name = validate_name(skill.name)
|
||||
base = self._base(scope, workspace)
|
||||
folder = self._folder_of(base, name)
|
||||
if (folder / "SKILL.md").is_file():
|
||||
raise ValueError(f"A skill named '{name}' already exists in that scope.")
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(staged), str(folder))
|
||||
# Stamp provenance so the Settings screen can distinguish uploaded from local.
|
||||
if not _frontmatter_source(folder / "SKILL.md"):
|
||||
_write_skill_md(
|
||||
folder,
|
||||
name=name,
|
||||
description=skill.description,
|
||||
instructions=skill.instructions,
|
||||
source="uploaded",
|
||||
)
|
||||
return {"name": name, "scope": scope, "path": str(folder)}
|
||||
|
||||
def discard_upload(self, token: str) -> None:
|
||||
staged = self._staging_dir / str(token)
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
|
||||
|
||||
class SessionSkillStore:
|
||||
"""``{session_id: {skill: bool}}`` — per-session mutes only; an absent entry means the
|
||||
session inherits (enabled unless disabled in Settings). Mirrors SessionConnectionStore."""
|
||||
|
||||
def __init__(self, path: Optional[str | Path] = None) -> None:
|
||||
self.path = Path(path) if path else None
|
||||
self._lock = threading.Lock()
|
||||
self._rows: dict[str, dict[str, bool]] = {}
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
if self.path and self.path.is_file():
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
self._rows = {
|
||||
sid: {str(s): bool(v) for s, v in (row or {}).items()}
|
||||
for sid, row in data.get("sessions", {}).items()
|
||||
}
|
||||
|
||||
def _save(self) -> None:
|
||||
if not self.path:
|
||||
return
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(
|
||||
json.dumps({"sessions": self._rows}, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
def get(self, session_id: str) -> dict[str, bool]:
|
||||
return dict(self._rows.get(session_id, {}))
|
||||
|
||||
def set(self, session_id: str, skill: str, enabled: bool) -> None:
|
||||
with self._lock:
|
||||
self._rows.setdefault(session_id, {})[skill] = bool(enabled)
|
||||
self._save()
|
||||
|
||||
def clear(self, session_id: str, skill: str) -> None:
|
||||
with self._lock:
|
||||
row = self._rows.get(session_id)
|
||||
if row and skill in row:
|
||||
del row[skill]
|
||||
if not row:
|
||||
del self._rows[session_id]
|
||||
self._save()
|
||||
|
||||
def remove_session(self, session_id: str) -> None:
|
||||
with self._lock:
|
||||
if session_id in self._rows:
|
||||
del self._rows[session_id]
|
||||
self._save()
|
||||
|
||||
|
||||
def effective_skills(
|
||||
*,
|
||||
names: set[str],
|
||||
disabled: set[str],
|
||||
session_overrides: dict[str, bool],
|
||||
) -> set[str]:
|
||||
"""The single source of truth for a session's skill menu (SKILLS-SPEC §3): any-off-wins.
|
||||
A Settings disable removes the skill everywhere — a session override can NOT resurrect
|
||||
it. Absent any opinion, a skill is on."""
|
||||
out: set[str] = set()
|
||||
for name in names:
|
||||
if name in disabled:
|
||||
continue
|
||||
if not session_overrides.get(name, True):
|
||||
continue
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
# -- the worker-authors door (SKILLS-SPEC §5.2) -------------------------------------
|
||||
|
||||
_SAVE_SKILL_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "save_skill",
|
||||
"description": (
|
||||
"Propose adding a finished skill to the user's skills. The user reviews the "
|
||||
"name, description, full instructions, and any bundled files on an approval "
|
||||
"card before anything is saved; once they approve, the skill is usable in "
|
||||
"every conversation. Use this after building or refining a skill in "
|
||||
"conversation, and offer it in words like: 'Want me to add <name> to your "
|
||||
"skills?' — say 'your skills', never the app name; say 'add', never "
|
||||
"'install'. If a skill with this name already exists, approving overwrites "
|
||||
"its instructions and adds the files."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Short folder-safe skill name (letters, digits, dots, dashes, underscores).",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "One line saying when the skill applies — this is its menu entry.",
|
||||
},
|
||||
"instructions": {
|
||||
"type": "string",
|
||||
"description": "The full instruction body (markdown). Becomes SKILL.md.",
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"Optional paths of files in this session's folders to bundle into "
|
||||
"the skill (scripts, examples, README). Copied in by basename."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["name", "description", "instructions"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def save_skill_tool(
|
||||
store: Optional[SkillStore] = None,
|
||||
*,
|
||||
allowed_dirs: Optional[list[str | Path]] = None,
|
||||
) -> Callable:
|
||||
"""Build the `save_skill` tool (SKILLS-SPEC §5.2). `requires_approval=True` routes every
|
||||
call through the standard approval card — the tool's ARGUMENTS are the review surface,
|
||||
which is why the schema carries the full instructions and file list. Bundled files may
|
||||
only be read from `allowed_dirs` (the session's roots): the worker must never bundle
|
||||
arbitrary machine paths into a skill."""
|
||||
store = store or SkillStore()
|
||||
dirs: list[Path] = []
|
||||
for d in allowed_dirs or []:
|
||||
try:
|
||||
dirs.append(Path(d).expanduser().resolve())
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
def save_skill(
|
||||
name: str,
|
||||
description: str = "",
|
||||
instructions: str = "",
|
||||
files: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
name = validate_name(name)
|
||||
except ValueError as exc:
|
||||
return {"error": str(exc)}
|
||||
if not (description or "").strip():
|
||||
return {"error": "A one-line description is required — it becomes the skill's menu entry."}
|
||||
if not (instructions or "").strip():
|
||||
return {"error": "Skill instructions are required."}
|
||||
|
||||
# Resolve + vet the bundle BEFORE touching disk, so a bad file never leaves a
|
||||
# half-written skill behind.
|
||||
staged: list[tuple[Path, str]] = []
|
||||
for raw in files or []:
|
||||
p = Path(str(raw)).expanduser()
|
||||
if not p.is_absolute():
|
||||
if not dirs:
|
||||
return {"error": f"File is outside this session's folders: {raw}"}
|
||||
p = dirs[0] / p
|
||||
try:
|
||||
rp = p.resolve()
|
||||
except OSError:
|
||||
return {"error": f"Unreadable file: {raw}"}
|
||||
if not rp.is_file():
|
||||
return {"error": f"Not a file: {raw}"}
|
||||
if not any(d == rp or d in rp.parents for d in dirs):
|
||||
return {"error": f"File is outside this session's folders: {raw}"}
|
||||
base = rp.name
|
||||
if base.lower() == "skill.md":
|
||||
# The instructions argument BECOMES SKILL.md; models routinely draft one in
|
||||
# the workspace and bundle it. Skip silently — erroring here cost the user a
|
||||
# second approval round for a self-healing retry (live drive 2026-07-27).
|
||||
continue
|
||||
if any(base == b for _, b in staged):
|
||||
return {"error": f"Duplicate bundled filename: {base}"}
|
||||
staged.append((rp, base))
|
||||
|
||||
# Worker-authored skills always land GLOBAL (§3.4: never a throwaway location).
|
||||
try:
|
||||
folder, _scope = store.find(name)
|
||||
action = "updated"
|
||||
store.update(name, description=description.strip(), instructions=instructions)
|
||||
except ValueError:
|
||||
action = "added"
|
||||
created = store.create(
|
||||
name=name, description=description.strip(), instructions=instructions
|
||||
)
|
||||
folder = Path(created["path"])
|
||||
for src, base in staged:
|
||||
shutil.copy2(src, folder / base)
|
||||
return {
|
||||
"ok": True,
|
||||
"name": name,
|
||||
"action": action,
|
||||
"files": [b for _, b in staged],
|
||||
"note": (
|
||||
"Saved to the user's skills — usable in every conversation from now on. "
|
||||
"Confirm in one short sentence. To browse the installed files, point the "
|
||||
"user to Settings > Skills (the file-count chip opens the folder) — do NOT "
|
||||
"link the workspace build folder as an artifact; folders don't open there."
|
||||
),
|
||||
}
|
||||
|
||||
save_skill.__name__ = "save_skill"
|
||||
save_skill.__doc__ = _SAVE_SKILL_SCHEMA["function"]["description"]
|
||||
save_skill.__aisuite_tool_metadata__ = ai.ToolMetadata(
|
||||
name="save_skill",
|
||||
category="skills",
|
||||
risk_level="medium",
|
||||
capabilities=["save_skill"],
|
||||
requires_approval=True,
|
||||
)
|
||||
save_skill.__coworker_schema__ = _SAVE_SKILL_SCHEMA
|
||||
return save_skill
|
||||
+211
-14
@@ -6,36 +6,136 @@ plus `multi` for choose-several. Like `request_directory`, it's intercepted by t
|
||||
question becomes an Inbox item (answerable inline in the live session, or from the Inbox when the
|
||||
session runs unattended), the agent suspends until it's resolved, and the answer comes back as the
|
||||
tool result. The callable here is only a schema carrier + a safe fallback.
|
||||
|
||||
OPE-51 upgrades: options may be rich objects ({label, description, recommended, preview}) instead
|
||||
of plain strings, and `questions` groups up to 4 questions into ONE call (rendered as a stepper —
|
||||
one agent round-trip instead of several). Plain-string options and the singular `question` form
|
||||
stay valid: old sessions and simple asks render exactly as before.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from aisuite.agents import ToolMetadata, tool
|
||||
|
||||
# How many questions one grouped call may carry (stepper chips get unreadable past this).
|
||||
MAX_GROUPED_QUESTIONS = 4
|
||||
|
||||
# An option is a plain string OR a rich object. `label` is what the user picks (and what comes
|
||||
# back as the answer); `description` renders under it; `recommended` adds the green tag (put the
|
||||
# recommended option first); `preview` is monospace text shown in the side pane (code, config,
|
||||
# ASCII mockups, SQL — any text; when ≥1 option has one the card switches to two-pane layout).
|
||||
_OPTION_SCHEMA = {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"recommended": {"type": "boolean"},
|
||||
"preview": {"type": "string"},
|
||||
},
|
||||
"required": ["label"],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
# Explicit schema (same pattern as todo.py): the string-or-object option union and the nested
|
||||
# `questions` array can't be auto-generated from the signature reliably.
|
||||
_ASK_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ask_user",
|
||||
"description": (
|
||||
"Ask the user one or more questions and wait for their answer. Use for decisions or "
|
||||
"information only the user can provide. Group related questions (up to "
|
||||
f"{MAX_GROUPED_QUESTIONS}) into one call via `questions` instead of asking serially."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": "The full question, in plain language (single-question form).",
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": _OPTION_SCHEMA,
|
||||
"description": (
|
||||
"Optional quick-reply choices: plain strings, or objects with `label` "
|
||||
"(required — this is the answer value), `description` (why/when to pick "
|
||||
"it), `recommended` (green tag; list that option first), and `preview` "
|
||||
"(monospace text — code, config, a mockup — shown in a side pane)."
|
||||
),
|
||||
},
|
||||
"allow_text": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Keep a free-text answer available even when options exist (default true; "
|
||||
"the \"Other / type your own\" escape). Set false only when the options "
|
||||
"are exhaustive."
|
||||
),
|
||||
},
|
||||
"multi": {
|
||||
"type": "boolean",
|
||||
"description": "Allow the user to pick more than one option.",
|
||||
},
|
||||
"header": {
|
||||
"type": "string",
|
||||
"description": "Short (≤ ~12 char) chip label for the card, e.g. \"Region\".",
|
||||
},
|
||||
"questions": {
|
||||
"type": "array",
|
||||
"maxItems": MAX_GROUPED_QUESTIONS,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {"type": "string"},
|
||||
"header": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Short (≤ ~12 char) label — names this step in the stepper "
|
||||
"chips and keys its answer in the result."
|
||||
),
|
||||
},
|
||||
"options": {"type": "array", "items": _OPTION_SCHEMA},
|
||||
"allow_text": {"type": "boolean"},
|
||||
"multi": {"type": "boolean"},
|
||||
},
|
||||
"required": ["question"],
|
||||
},
|
||||
"description": (
|
||||
f"Grouped form: up to {MAX_GROUPED_QUESTIONS} questions asked in ONE "
|
||||
"round-trip, rendered as a stepper. When set, the singular "
|
||||
"question/options fields are ignored."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def ask_user_tool() -> object:
|
||||
def ask_user(
|
||||
question: str,
|
||||
options: list[str] | None = None,
|
||||
question: str = "",
|
||||
options: list | None = None,
|
||||
allow_text: bool = True,
|
||||
multi: bool = False,
|
||||
header: str = "",
|
||||
questions: list | None = None,
|
||||
) -> dict:
|
||||
"""Ask the user a question and wait for their answer — use when you genuinely need a human
|
||||
decision or information you can't infer (a preference, a missing fact, a choice between real
|
||||
alternatives). Prefer this over guessing or stalling.
|
||||
|
||||
- `question`: the full question, in plain language.
|
||||
- `options`: optional quick-reply choices. Offer them when the answer is one of a few
|
||||
discrete alternatives; leave empty for an open-ended question.
|
||||
- `allow_text`: keep a free-text answer available even when you give options (the default;
|
||||
this is the "Other / type your own" escape). Set False only when the options are
|
||||
exhaustive and a typed answer would be meaningless.
|
||||
- `multi`: allow the user to pick more than one option.
|
||||
- `header`: a short (≤ ~12 char) label for the Inbox card chip, e.g. "Region".
|
||||
|
||||
Returns `{"answer": "..."}` — the chosen option(s) or the typed text. Don't ask what you can
|
||||
reasonably decide yourself; reserve this for choices that are actually the user's to make.
|
||||
Single form returns `{"answer": "..."}` — the chosen option label(s) or the typed text.
|
||||
Grouped form (`questions`) returns `{"answers": {"<header or question>": "..."}}` — one
|
||||
entry per question. Don't ask what you can reasonably decide yourself; reserve this for
|
||||
choices that are actually the user's to make.
|
||||
"""
|
||||
# Real handling lives in the engine (it needs the out-of-band Inbox round-trip). This body
|
||||
# only runs if no question_asker is wired (e.g. a headless surface).
|
||||
@@ -44,7 +144,7 @@ def ask_user_tool() -> object:
|
||||
"error": "asking the user isn't available in this surface",
|
||||
}
|
||||
|
||||
return tool(
|
||||
wrapped = tool(
|
||||
ask_user,
|
||||
metadata=ToolMetadata(
|
||||
category="interaction",
|
||||
@@ -56,3 +156,100 @@ def ask_user_tool() -> object:
|
||||
),
|
||||
),
|
||||
)
|
||||
wrapped.__coworker_schema__ = _ASK_SCHEMA
|
||||
return wrapped
|
||||
|
||||
|
||||
def normalize_option(opt) -> dict:
|
||||
"""One option in canonical dict form: {label, description, recommended, preview}. Plain
|
||||
strings become {label: str, ...empty}. The label doubles as the answer value everywhere
|
||||
(buttons, pills, resolutions), so it is always a non-empty-able str."""
|
||||
if isinstance(opt, dict):
|
||||
return {
|
||||
"label": str(opt.get("label", "")),
|
||||
"description": str(opt.get("description", "")),
|
||||
"recommended": bool(opt.get("recommended", False)),
|
||||
"preview": str(opt.get("preview", "")),
|
||||
}
|
||||
return {"label": str(opt), "description": "", "recommended": False, "preview": ""}
|
||||
|
||||
|
||||
def option_label(opt) -> str:
|
||||
"""The answer value / button text for a str-or-dict option."""
|
||||
return str(opt.get("label", "")) if isinstance(opt, dict) else str(opt)
|
||||
|
||||
|
||||
def normalize_questions(raw) -> list[dict]:
|
||||
"""The grouped `questions` arg in canonical form (capped, blanks dropped). Each entry:
|
||||
{question, header, options: [canonical option], allow_text, multi}."""
|
||||
out: list[dict] = []
|
||||
for entry in list(raw or [])[:MAX_GROUPED_QUESTIONS]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
q = str(entry.get("question", "")).strip()
|
||||
if not q:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"question": q,
|
||||
"header": str(entry.get("header", "")),
|
||||
"options": [normalize_option(o) for o in entry.get("options") or []],
|
||||
"allow_text": bool(entry.get("allow_text", True)),
|
||||
"multi": bool(entry.get("multi", False)),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def question_item_fields(args: dict) -> dict | None:
|
||||
"""`InboxStore.add_question` kwargs from raw ask_user args, or None when nothing was asked.
|
||||
A grouped call surfaces its FIRST question as title/options too, so legacy surfaces (channel
|
||||
mirrors, old persisted-item readers) degrade to a sensible single question."""
|
||||
grouped = normalize_questions(args.get("questions"))
|
||||
if grouped:
|
||||
first = grouped[0]
|
||||
return {
|
||||
"title": first["question"],
|
||||
"options": first["options"],
|
||||
"allow_text": first["allow_text"],
|
||||
"multi": first["multi"],
|
||||
"header": first["header"],
|
||||
"questions": grouped,
|
||||
}
|
||||
question = str(args.get("question", "")).strip()
|
||||
if not question:
|
||||
return None
|
||||
return {
|
||||
"title": question,
|
||||
# Strings pass through untouched (simple asks keep rendering as today's pills);
|
||||
# rich objects are canonicalized so downstream never meets a half-filled dict.
|
||||
"options": [
|
||||
o if isinstance(o, str) else normalize_option(o)
|
||||
for o in args.get("options") or []
|
||||
],
|
||||
"allow_text": bool(args.get("allow_text", True)),
|
||||
"multi": bool(args.get("multi", False)),
|
||||
"header": str(args.get("header", "")),
|
||||
"questions": [],
|
||||
}
|
||||
|
||||
|
||||
def answer_result(item_questions: list, resolution: str | None) -> dict:
|
||||
"""Shape the ask_user tool result from an Inbox item's resolution string. Grouped items
|
||||
resolve with a JSON object string keyed by header-or-question → `{"answers": {...}}`;
|
||||
everything else returns the plain `{"answer": str}` shape."""
|
||||
if item_questions:
|
||||
try:
|
||||
parsed = json.loads(resolution or "")
|
||||
except (ValueError, TypeError):
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
return {"answers": {str(k): str(v) for k, v in parsed.items()}}
|
||||
if resolution:
|
||||
# Answered from a text-only surface (e.g. a mirrored channel): attribute the lone
|
||||
# answer to the first question rather than losing it.
|
||||
first = item_questions[0] if isinstance(item_questions[0], dict) else {}
|
||||
key = str(first.get("header") or first.get("question") or "answer")
|
||||
return {"answers": {key: str(resolution)}}
|
||||
return {"answer": ""}
|
||||
return {"answer": resolution or ""}
|
||||
|
||||
@@ -16,6 +16,18 @@ from typing import Any, Optional
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
# Per-OS application data directories. These are not build noise: on macOS 14+ merely
|
||||
# *descending* into ~/Library/Application Support (other apps' containers) trips the App
|
||||
# Data TCC protection and macOS shows "would like to access data from other apps" — an
|
||||
# alarming prompt the user never asked for, reachable whenever the workspace is a home
|
||||
# directory. Never traversed; a workspace under one of these is still searched normally,
|
||||
# because the guard matches directory NAMES encountered during a walk.
|
||||
OS_DATA_DIRS = {
|
||||
"Library", # macOS
|
||||
"AppData", # Windows
|
||||
"Application Data", # Windows (legacy junction)
|
||||
}
|
||||
|
||||
_IGNORE_DIRS = {
|
||||
".git",
|
||||
"node_modules",
|
||||
@@ -30,7 +42,7 @@ _IGNORE_DIRS = {
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
".idea",
|
||||
}
|
||||
} | OS_DATA_DIRS
|
||||
|
||||
_SCHEMA = {
|
||||
"type": "function",
|
||||
|
||||
@@ -13,6 +13,8 @@ from typing import Any, Callable
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
from .guard import get_checked
|
||||
|
||||
_MAX = 20000 # default chars returned
|
||||
|
||||
_SCHEMA = {
|
||||
@@ -84,16 +86,20 @@ def make_web_fetch_tool() -> Callable[..., Any]:
|
||||
try:
|
||||
import httpx
|
||||
|
||||
# follow_redirects=False: guard.get_checked walks the chain so every hop is
|
||||
# address-checked, not just the URL the model first supplied.
|
||||
with httpx.Client(
|
||||
follow_redirects=True,
|
||||
follow_redirects=False,
|
||||
timeout=20.0,
|
||||
headers={"User-Agent": "coworker/0.1 (+desktop)"},
|
||||
) as client:
|
||||
resp = client.get(url)
|
||||
resp = get_checked(client, url)
|
||||
resp.raise_for_status()
|
||||
ctype = resp.headers.get("content-type", "")
|
||||
body = resp.text
|
||||
final_url = str(resp.url)
|
||||
except PermissionError as exc: # blocked address (loopback, private, metadata)
|
||||
return {"error": str(exc)}
|
||||
except Exception as exc: # network / HTTP / TLS
|
||||
return {"error": f"fetch failed: {exc}"}
|
||||
text = _html_to_text(body) if "html" in ctype.lower() else body
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Address guard for URLs the model chooses.
|
||||
|
||||
`web_fetch` and `browser_read_url` take a URL straight from the model, and the model's
|
||||
input is untrusted by design — it reads web pages, email and Slack messages, all of which
|
||||
are documented as "data, not instructions". A page that talks the agent into fetching
|
||||
`http://169.254.169.254/` or `http://127.0.0.1:11434/` turns a read-only research tool into
|
||||
a probe of the machine's own network position, and `web_fetch` is `requires_approval=False`,
|
||||
so no prompt ever appears.
|
||||
|
||||
This blocks the ranges that are only reachable *because* OpenWorker runs on the user's
|
||||
machine: loopback, RFC1918 and other private space, link-local (which covers the cloud
|
||||
metadata endpoint at 169.254.169.254), and the reserved/multicast blocks.
|
||||
|
||||
Every hop is checked, not just the first: `follow_redirects=True` otherwise lets a public
|
||||
URL 302 straight to loopback, which is the standard way this filter is bypassed.
|
||||
|
||||
Not covered: DNS rebinding. The name is resolved here and resolved again by the client when
|
||||
it connects, so a record with a ~0 TTL can change between the two. Closing that needs
|
||||
connection-level IP pinning; the hop check is the cheap 90% and is stated as such.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from typing import Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
MAX_REDIRECTS = 5
|
||||
|
||||
# RFC 6598 shared address space. Python's is_private misses it, but it is carrier grade
|
||||
# NAT space and Tailscale hands out internal hosts here (100.64.0.0/10), so a fetch to it
|
||||
# is the same "reach the machine's network position" class as RFC1918.
|
||||
_CGNAT = ipaddress.ip_network("100.64.0.0/10")
|
||||
|
||||
|
||||
def _blocked_reason(ip: ipaddress._BaseAddress) -> Optional[str]:
|
||||
if ip.is_loopback:
|
||||
return "loopback"
|
||||
if ip.is_link_local:
|
||||
return "link-local (includes the cloud metadata endpoint)"
|
||||
if ip.is_private:
|
||||
return "a private network"
|
||||
if ip.version == 4 and ip in _CGNAT:
|
||||
return "shared address space (CGNAT / RFC 6598)"
|
||||
if ip.is_multicast:
|
||||
return "multicast"
|
||||
if ip.is_reserved or ip.is_unspecified:
|
||||
return "a reserved range"
|
||||
return None
|
||||
|
||||
|
||||
def check_url(url: str) -> Optional[str]:
|
||||
"""None if the URL may be fetched, else a human-readable refusal reason.
|
||||
|
||||
Resolves the host and rejects when *any* answer lands in a blocked range, so a name
|
||||
with both a public and a private A record cannot be used to slip through.
|
||||
"""
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme not in ("http", "https"):
|
||||
return "url must start with http:// or https://"
|
||||
host = parts.hostname
|
||||
if not host:
|
||||
return "url has no host"
|
||||
|
||||
# A literal address needs no lookup.
|
||||
try:
|
||||
literal = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
literal = None
|
||||
if literal is not None:
|
||||
reason = _blocked_reason(literal)
|
||||
return f"refusing to fetch {host}: {reason}" if reason else None
|
||||
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, parts.port or (443 if parts.scheme == "https" else 80),
|
||||
proto=socket.IPPROTO_TCP)
|
||||
except OSError as exc:
|
||||
return f"could not resolve {host}: {exc}"
|
||||
|
||||
for info in infos:
|
||||
raw = info[4][0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
# ::ffff:127.0.0.1 and friends must be judged as the v4 address they carry.
|
||||
mapped = getattr(ip, "ipv4_mapped", None)
|
||||
if mapped is not None:
|
||||
ip = mapped
|
||||
reason = _blocked_reason(ip)
|
||||
if reason:
|
||||
return f"refusing to fetch {host} ({ip}): {reason}"
|
||||
return None
|
||||
|
||||
|
||||
def get_checked(client, url: str, *, max_redirects: int = MAX_REDIRECTS):
|
||||
"""GET `url`, validating the address before every hop.
|
||||
|
||||
`client` must be built with `follow_redirects=False`; redirects are walked here so each
|
||||
Location is checked. Returns the final response. Raises `PermissionError` when a hop is
|
||||
refused, `RuntimeError` when the redirect budget is exhausted.
|
||||
"""
|
||||
seen = url
|
||||
for _ in range(max_redirects + 1):
|
||||
reason = check_url(seen)
|
||||
if reason:
|
||||
raise PermissionError(reason)
|
||||
resp = client.get(seen)
|
||||
if resp.status_code not in (301, 302, 303, 307, 308):
|
||||
return resp
|
||||
location = resp.headers.get("location")
|
||||
if not location:
|
||||
return resp
|
||||
seen = str(resp.url.join(location))
|
||||
raise RuntimeError(f"too many redirects (>{max_redirects})")
|
||||
@@ -10,6 +10,7 @@ Looks for the updater artifacts by their STABLE names (the same names release.ym
|
||||
uploads):
|
||||
|
||||
OpenWorker-macos-arm64.app.tar.gz(.sig) -> platforms["darwin-aarch64"]
|
||||
OpenWorker-macos-x64.app.tar.gz(.sig) -> platforms["darwin-x86_64"]
|
||||
OpenWorker-windows-setup.exe(.sig) -> platforms["windows-x86_64"]
|
||||
|
||||
URLs point at the TAG-pinned GitHub download path (releases/download/<tag>/<asset>),
|
||||
@@ -34,6 +35,7 @@ import sys
|
||||
# stable asset name -> Tauri platform key
|
||||
ARTIFACTS = {
|
||||
"OpenWorker-macos-arm64.app.tar.gz": "darwin-aarch64",
|
||||
"OpenWorker-macos-x64.app.tar.gz": "darwin-x86_64",
|
||||
"OpenWorker-windows-setup.exe": "windows-x86_64",
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ test("run_shell → full card: description title, command preview, stays-on-this
|
||||
// The mocked proposal has no description → plain "Run a command" title; the command is
|
||||
// the preview; the reason still renders; the scope note replaces the old badge.
|
||||
await expect(page.getByText("Run a command").last()).toBeVisible();
|
||||
await expect(page.getByText("stays on this Mac").last()).toBeVisible();
|
||||
await expect(page.getByText("stays on this computer").last()).toBeVisible();
|
||||
await expect(page.getByText("The coworker wants to run a command.").first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Always allow this command" }).last()).toBeVisible();
|
||||
await expect(page.getByText(/local action/)).toHaveCount(0);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
// OPE-51 — ask_user upgrades: rich options (descriptions, the Recommended tag, monospace
|
||||
// previews with the two-pane layout) and grouped questions (the stepper). Seeded via a per-test
|
||||
// inbox route override (later routes match first) so the base fixtures' counts — which
|
||||
// inbox.spec.ts pins — stay untouched.
|
||||
|
||||
const BASE = {
|
||||
body: "",
|
||||
state: "pending",
|
||||
resolution: null as string | null,
|
||||
inbox: "default",
|
||||
created_at: "2026-07-29 08:00:00",
|
||||
resolved_at: null as string | null,
|
||||
session_title: "Investigate alerts",
|
||||
session_agent: "ops",
|
||||
session_workspace: "",
|
||||
session_exists: true,
|
||||
};
|
||||
|
||||
const RICH_ITEM = {
|
||||
...BASE,
|
||||
id: "inb-question-rich",
|
||||
session_id: "ops-1",
|
||||
kind: "question",
|
||||
title: "How should I format the report?",
|
||||
header: "Format",
|
||||
options: [
|
||||
{
|
||||
label: "Markdown table",
|
||||
description: "Compact and renders in the app",
|
||||
recommended: true,
|
||||
preview: "| env | status |\n| --- | --- |\n| staging | ok |",
|
||||
},
|
||||
{
|
||||
label: "Plain text",
|
||||
description: "Safest for email forwarding",
|
||||
preview: "env: staging\nstatus: ok",
|
||||
},
|
||||
],
|
||||
allow_text: true,
|
||||
multi: false,
|
||||
questions: [],
|
||||
};
|
||||
|
||||
const GROUPED_ITEM = {
|
||||
...BASE,
|
||||
id: "inb-question-grouped",
|
||||
session_id: "ops-1",
|
||||
kind: "question",
|
||||
// The first question doubles as title/options (legacy-surface degradation, server parity).
|
||||
title: "Chart style?",
|
||||
header: "Chart style",
|
||||
options: ["Bar", "Line"],
|
||||
allow_text: false,
|
||||
multi: false,
|
||||
questions: [
|
||||
{ question: "Chart style?", header: "Chart style", options: ["Bar", "Line"], allow_text: false, multi: false },
|
||||
{ question: "Which distribution?", header: "Distribution", options: ["Stacked", "Grouped"], allow_text: true, multi: false },
|
||||
],
|
||||
};
|
||||
|
||||
/** Replace the Inbox's seeded items for this test (resolve mutates the local copy). */
|
||||
async function seedInbox(page: Page, items: Record<string, unknown>[]) {
|
||||
const inbox = items.map((i) => ({ ...i }));
|
||||
const json = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
await page.route(/\/v1\/inbox\/[^/]+\/resolve$/, (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
const id = decodeURIComponent(path.split("/").slice(-2)[0]);
|
||||
const it = inbox.find((x) => x.id === id);
|
||||
if (it) {
|
||||
it.state = "resolved";
|
||||
it.resolution = route.request().postDataJSON().resolution;
|
||||
}
|
||||
return route.fulfill(json({ ok: true }));
|
||||
});
|
||||
await page.route(/\/v1\/inbox(\?.*)?$/, (route) =>
|
||||
route.fulfill(json({ items: inbox.filter((i) => i.state === "pending") })),
|
||||
);
|
||||
return inbox;
|
||||
}
|
||||
|
||||
async function openInbox(page: Page, expectTitle: string) {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("inbox-chip").click();
|
||||
await expect(page.getByText(expectTitle)).toBeVisible();
|
||||
}
|
||||
|
||||
test("rich options render descriptions + Recommended; the preview pane follows hover", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedInbox(page, [RICH_ITEM]);
|
||||
await openInbox(page, "How should I format the report?");
|
||||
|
||||
await expect(page.getByText("Compact and renders in the app")).toBeVisible();
|
||||
await expect(page.getByText("Recommended")).toBeVisible();
|
||||
|
||||
// The pane opens on the first option holding a preview…
|
||||
const pane = page.getByTestId("question-preview");
|
||||
await expect(pane).toContainText("| env | status |");
|
||||
// …and follows hover to the other option.
|
||||
await page.getByRole("button", { name: /Plain text/ }).hover();
|
||||
await expect(pane).toContainText("env: staging");
|
||||
|
||||
// Single-select still resolves on click, with the option's LABEL as the resolution.
|
||||
const resolved = page.waitForRequest(
|
||||
(r) => r.url().includes("/resolve") && r.method() === "POST",
|
||||
);
|
||||
await page.getByRole("button", { name: /Markdown table/ }).click();
|
||||
expect((await resolved).postDataJSON().resolution).toBe("Markdown table");
|
||||
await expect(page.getByText("How should I format the report?")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("grouped questions step through the header chips and resolve as one answer map", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedInbox(page, [GROUPED_ITEM]);
|
||||
await openInbox(page, "Chart style?");
|
||||
|
||||
// Step 1: "Chart style · 1 of 2 · Distribution ›" — and no free-text row (allow_text: false).
|
||||
const stepper = page.getByTestId("question-stepper");
|
||||
await expect(stepper).toContainText("Chart style");
|
||||
await expect(stepper).toContainText("1 of 2");
|
||||
await expect(stepper).toContainText("Distribution ›");
|
||||
await expect(page.getByPlaceholder("Or type your own answer…")).not.toBeVisible();
|
||||
|
||||
// Answering advances to step 2 (its free-text escape is back — allow_text: true).
|
||||
await page.getByRole("button", { name: "Bar", exact: true }).click();
|
||||
await expect(stepper).toContainText("2 of 2");
|
||||
await expect(page.getByText("Which distribution?")).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Or type your own answer…")).toBeVisible();
|
||||
|
||||
// ‹ steps back with the first answer re-askable; answer forward again.
|
||||
await page.getByRole("button", { name: "Previous question" }).click();
|
||||
await expect(stepper).toContainText("1 of 2");
|
||||
await page.getByRole("button", { name: "Bar", exact: true }).click();
|
||||
await expect(stepper).toContainText("2 of 2");
|
||||
|
||||
// The final answer resolves the whole card with a JSON map keyed by header.
|
||||
const resolved = page.waitForRequest(
|
||||
(r) => r.url().includes("/resolve") && r.method() === "POST",
|
||||
);
|
||||
await page.getByRole("button", { name: "Stacked", exact: true }).click();
|
||||
expect((await resolved).postDataJSON().resolution).toBe(
|
||||
JSON.stringify({ "Chart style": "Bar", Distribution: "Stacked" }),
|
||||
);
|
||||
await expect(page.getByText("Nothing pending.")).toBeVisible();
|
||||
});
|
||||
@@ -57,3 +57,37 @@ test("approval: Deny skips the tool and the agent says so", async ({ page }) =>
|
||||
await page.getByRole("button", { name: "Deny" }).last().click();
|
||||
await expect(page.getByText("Understood — skipped the command.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("long user pastes clamp with a more…/less… toggle", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
await expect(box).toBeVisible();
|
||||
|
||||
const tail = "END-OF-PASTE-MARKER";
|
||||
const paste =
|
||||
"reply OK. " + "lorem ipsum dolor sit amet consectetur ".repeat(60) + tail; // ~2.4k chars
|
||||
await box.fill(paste);
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
// Clamped: the bubble shows the head but not the tail, plus the toggle.
|
||||
const more = page.getByRole("button", { name: "more…" });
|
||||
await expect(more).toBeVisible();
|
||||
const bubble = page.locator(".bubble-user").last();
|
||||
await expect(bubble).toContainText("reply OK.");
|
||||
await expect(bubble).not.toContainText(tail);
|
||||
|
||||
// Expand → full text + "less…"; collapse → clamped again.
|
||||
await more.click();
|
||||
await expect(bubble).toContainText(tail);
|
||||
const less = page.getByRole("button", { name: "less…" });
|
||||
await expect(less).toBeVisible();
|
||||
await less.click();
|
||||
await expect(bubble).not.toContainText(tail);
|
||||
|
||||
// Short messages never show the control.
|
||||
await expect(page.getByText("Echo:").first()).toBeVisible();
|
||||
await box.fill("short follow-up");
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
await expect(page.getByText("short follow-up", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "more…" })).toHaveCount(1); // still only the paste's
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// OPE-27 — auto-compaction GUI: the Settings card's two overrides + summarizer-model
|
||||
// pin POST through, and the "context compacted" divider renders inline mid-session
|
||||
// (driven by the fixtures' scripted `compacted` event) without touching the transcript.
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
|
||||
test("Settings: Context compaction card edits threshold, cap, and summarizer model", 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: "Models", exact: true }).click();
|
||||
|
||||
const card = page.getByTestId("compaction-card");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByText("Context compaction")).toBeVisible();
|
||||
|
||||
// Defaults render when the backend doesn't send the fields (older-backend robustness).
|
||||
await expect(card.getByTestId("compaction-threshold")).toHaveValue("80");
|
||||
await expect(card.getByTestId("compaction-cap")).toHaveValue("250000");
|
||||
await expect(card.getByTestId("compaction-model")).toHaveValue("");
|
||||
|
||||
// Threshold edits POST as a fraction, clamped to 10–95%.
|
||||
const [req] = await Promise.all([
|
||||
page.waitForRequest(
|
||||
(r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
|
||||
),
|
||||
card.getByTestId("compaction-threshold").fill("70"),
|
||||
]);
|
||||
expect(req.postDataJSON()).toEqual({ compaction_threshold_pct: 0.7 });
|
||||
|
||||
const [req2] = await Promise.all([
|
||||
page.waitForRequest(
|
||||
(r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
|
||||
),
|
||||
card.getByTestId("compaction-cap").fill("100000"),
|
||||
]);
|
||||
expect(req2.postDataJSON()).toEqual({ compaction_cap_tokens: 100000 });
|
||||
|
||||
// Summarizer pin: the picker offers the session-default plus the configured models.
|
||||
const [req3] = await Promise.all([
|
||||
page.waitForRequest(
|
||||
(r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
|
||||
),
|
||||
card.getByTestId("compaction-model").selectOption("gpt-4o-mini"),
|
||||
]);
|
||||
expect(req3.postDataJSON()).toEqual({ compaction_model: "gpt-4o-mini" });
|
||||
});
|
||||
|
||||
test("the compacted divider renders mid-session and the transcript stays intact", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByText("Draft the launch note").first().click();
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
|
||||
// An earlier exchange that must survive the compaction marker (transcript intact).
|
||||
await box.fill("remember the launch date");
|
||||
await box.press("Enter");
|
||||
await expect(page.getByText("Echo: remember the launch date").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await box.fill("compact the context");
|
||||
await box.press("Enter");
|
||||
// The transient signal shows while the summarizer runs, then yields to the divider.
|
||||
await expect(page.getByText("Compacting context…").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByText("Context compacted — earlier turns were summarized").first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("Compacting context…")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText("Still on it — continuing where I left off.").first(),
|
||||
).toBeVisible();
|
||||
// Outbound-only: everything before the divider is still on screen.
|
||||
await expect(page.getByText("Echo: remember the launch date").first()).toBeVisible();
|
||||
});
|
||||
@@ -158,7 +158,7 @@ const CONNECTORS = {
|
||||
// MCP-BACKED connectors (§42): vendor-hosted MCP + local OAuth, pinned tool subset.
|
||||
// monday is one-click ONLY (no manual fields); jira also has a manual token path
|
||||
// (two-mode modal). Neither needs cloud sign-in.
|
||||
{ name: "monday", title: "monday.com", icon: "▦", blurb: "Read boards and items, track work, create items and post updates.", aliases: ["project management", "tasks", "boards"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#6161ff", logo: "monday", mcp: true, fields: [], instructions: ["One click connects via monday.com sign-in in your browser.", "Sign-in is fully local — tokens stay on this Mac."], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "mcp__monday__get_board_info", label: "Read board", kind: "read", description: "Read a board's columns and groups.", enabled: true, requires_approval: false }, { name: "mcp__monday__create_item", label: "Create item", kind: "write", description: "Create an item on a board.", enabled: true, requires_approval: true }], managed: false, managed_profile: false },
|
||||
{ name: "monday", title: "monday.com", icon: "▦", blurb: "Read boards and items, track work, create items and post updates.", aliases: ["project management", "tasks", "boards"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#6161ff", logo: "monday", mcp: true, fields: [], instructions: ["One click connects via monday.com sign-in in your browser.", "Sign-in is fully local — tokens stay on this computer."], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "mcp__monday__get_board_info", label: "Read board", kind: "read", description: "Read a board's columns and groups.", enabled: true, requires_approval: false }, { name: "mcp__monday__create_item", label: "Create item", kind: "write", description: "Create an item on a board.", enabled: true, requires_approval: true }], managed: false, managed_profile: false },
|
||||
{ name: "jira", title: "Jira", icon: "◆", blurb: "Search, summarize, create, and update issues.", aliases: ["issues", "tickets", "atlassian"], auth: "api_token", two_way: false, channels: false, available: true, brand_color: "#0052cc", logo: "jira", mcp: true, fields: [{ key: "base_url", label: "Atlassian site URL", secret: false, required: true, help: "", placeholder: "" }, { key: "email", label: "Account email", secret: false, required: true, help: "", placeholder: "" }, { key: "api_token", label: "API token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: false, managed_profile: false },
|
||||
],
|
||||
};
|
||||
@@ -552,6 +552,14 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
// Per-session unattended flag — mutable so the composer's "Send to Inbox" toggle persists and
|
||||
// the app reads it back (which is what gates parking approvals to the Inbox vs an inline card).
|
||||
const unattended: Record<string, boolean> = {};
|
||||
// Skills (SKILLS-SPEC) — mutable folder-is-truth mirror: Settings CRUD, the enabled flag,
|
||||
// staged uploads (stage → preview → confirm), and the composer's per-session menu all
|
||||
// round-trip through this one list.
|
||||
const skills: any[] = [
|
||||
{ name: "weekly-report", description: "Monday status report", instructions: "1. Collect updates\n2. Write it up", scope: "global", source: "local", enabled: true, path: "/state/skills/weekly-report", files: 0 },
|
||||
{ name: "html-to-markdown", description: "Convert an HTML document or fragment to clean markdown.", instructions: "Convert the given HTML to markdown, preserving structure.", scope: "global", source: "uploaded", enabled: true, path: "/state/skills/html-to-markdown", files: 2 },
|
||||
];
|
||||
let stagedSkill: any = null;
|
||||
|
||||
// Fresh cloud sign-in state per test (module state outlives a page).
|
||||
Object.assign(CLOUD_STATE, {
|
||||
@@ -585,7 +593,12 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
const msg = JSON.parse(String(raw));
|
||||
if (msg.type === "user_message") {
|
||||
hadTurn = true;
|
||||
send("turn_start", { input: msg.text });
|
||||
// Force-run (SKILLS-SPEC §6): like the real server, TURN_START ships the user's
|
||||
// literal "/name …" line as `display` so the client dedupes on what the user sees.
|
||||
send("turn_start", {
|
||||
input: msg.text,
|
||||
...(msg.skill ? { display: `/${msg.skill}${msg.text ? ` ${msg.text}` : ""}` } : {}),
|
||||
});
|
||||
if (/run a tool/i.test(msg.text)) {
|
||||
pendingTool = "run_shell";
|
||||
send("tool_proposed", { name: "run_shell", arguments: { command: "ls" } });
|
||||
@@ -681,6 +694,18 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
}, 120);
|
||||
return;
|
||||
}
|
||||
// Auto-compaction (OPE-27): the server signals `compacting` (the transient
|
||||
// spinner label), summarizes for a beat, then emits the marker and the turn
|
||||
// continues normally — the divider must render inline.
|
||||
if (/compact the context/i.test(msg.text)) {
|
||||
send("compacting", {});
|
||||
setTimeout(() => {
|
||||
send("compacted", { text: "Context compacted — earlier turns were summarized" });
|
||||
send("assistant_message", { text: "Still on it — continuing where I left off." });
|
||||
send("turn_done");
|
||||
}, 400);
|
||||
return;
|
||||
}
|
||||
// A turn that dies on a provider error; the follow-up {type:"retry"} recovers.
|
||||
if (/fail the turn/i.test(msg.text)) {
|
||||
send("error", { error: "model unreachable" });
|
||||
@@ -709,10 +734,11 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
send("assistant_delta", { text: msg.text });
|
||||
// Echo the model the message carried — pins the model-per-message contract (the
|
||||
// composer's visible model must ride on every user_message; 2026-07-04 fix).
|
||||
// Same for `skill`: the force-run pick must ride as its OWN FIELD, never as text.
|
||||
// `usage` mirrors the real engine's assistant_message sidecar (OPE-42): fixed
|
||||
// counts per turn so the usage-chip specs can assert exact accumulation.
|
||||
send("assistant_message", {
|
||||
text: `Echo: ${msg.text} [model=${msg.model || "none"}]`,
|
||||
text: `Echo: ${msg.text} [model=${msg.model || "none"}]${msg.skill ? ` [skill=${msg.skill}]` : ""}`,
|
||||
usage: {
|
||||
model: msg.model || "anthropic:claude-opus-4-8",
|
||||
input: 1_000,
|
||||
@@ -824,8 +850,79 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
return json(i >= 0 ? sessions[i] : PINNED_SESSION);
|
||||
}
|
||||
|
||||
// Skills (SKILLS-SPEC §5/§6). Order matters: upload/confirm before the {name} regexes.
|
||||
if (/\/v1\/sessions\/[^/]+\/skills$/.test(p)) {
|
||||
// The composer's live menu: every Settings-enabled skill (§4 — disabled = invisible).
|
||||
return json({
|
||||
skills: skills
|
||||
.filter((s) => s.enabled)
|
||||
.map((s) => ({ name: s.name, description: s.description, scope: s.scope, enabled: true })),
|
||||
});
|
||||
}
|
||||
if (p.endsWith("/v1/skills/upload/confirm") && m === "POST") {
|
||||
const b = req.postDataJSON() || {};
|
||||
if (!stagedSkill || b.token !== stagedSkill.token)
|
||||
return json({ ok: false, error: "Upload expired — pick the file again." });
|
||||
skills.push({
|
||||
name: stagedSkill.name, description: stagedSkill.description,
|
||||
instructions: stagedSkill.instructions, scope: "global", source: "uploaded",
|
||||
enabled: true, path: `/state/skills/${stagedSkill.name}`, files: stagedSkill.files.length,
|
||||
});
|
||||
stagedSkill = null;
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (p.endsWith("/v1/skills/upload") && m === "POST") {
|
||||
// Stage → preview; nothing lands until confirm. Fixed parse (the mock reads no zips).
|
||||
stagedSkill = {
|
||||
token: "stage-1", name: "greet", description: "says hello",
|
||||
instructions: "Say hello warmly.", files: ["notes.txt"],
|
||||
};
|
||||
return json({ ok: true, ...stagedSkill });
|
||||
}
|
||||
{
|
||||
const mr = p.match(/\/v1\/skills\/([^/]+)\/reveal$/);
|
||||
if (mr && m === "POST") {
|
||||
return json(
|
||||
skills.some((s) => s.name === decodeURIComponent(mr[1]))
|
||||
? { ok: true }
|
||||
: { ok: false, error: `Unknown skill: ${decodeURIComponent(mr[1])}` },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (/\/v1\/skills\/[^/]+$/.test(p) && (m === "PATCH" || m === "DELETE")) {
|
||||
const name = decodeURIComponent(p.split("/").pop()!);
|
||||
const i = skills.findIndex((s) => s.name === name);
|
||||
if (i < 0) return json({ ok: false, error: `Unknown skill: ${name}` });
|
||||
if (m === "DELETE") {
|
||||
skills.splice(i, 1);
|
||||
return json({ ok: true });
|
||||
}
|
||||
const b = req.postDataJSON() || {};
|
||||
if (typeof b.enabled === "boolean") skills[i].enabled = b.enabled;
|
||||
if (typeof b.description === "string") skills[i].description = b.description;
|
||||
if (typeof b.instructions === "string") skills[i].instructions = b.instructions;
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (p.endsWith("/v1/skills") && m === "POST") {
|
||||
const b = req.postDataJSON() || {};
|
||||
if (!b.name || !(b.instructions || "").trim())
|
||||
return json({ ok: false, error: "Skill name and instructions are required." });
|
||||
if (skills.some((s) => s.name === b.name))
|
||||
return json({ ok: false, error: `A skill named '${b.name}' already exists in that scope.` });
|
||||
skills.push({
|
||||
name: b.name, description: b.description || "", instructions: b.instructions,
|
||||
scope: "global", source: "local", enabled: true, path: `/state/skills/${b.name}`, files: 0,
|
||||
});
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (p.endsWith("/v1/skills")) return json({ skills });
|
||||
|
||||
if (p.endsWith("/v1/health")) return json(HEALTH);
|
||||
if (p.endsWith("/v1/settings")) return json(SETTINGS);
|
||||
if (p.endsWith("/v1/settings/context-bar") && m === "POST") {
|
||||
Object.assign(SETTINGS, req.postDataJSON());
|
||||
return json({ ok: true, context_bar: SETTINGS.context_bar });
|
||||
}
|
||||
if (p.endsWith("/v1/settings/pdf") && m === "POST") {
|
||||
Object.assign(SETTINGS, req.postDataJSON());
|
||||
return json({
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -20,7 +20,8 @@ test("usage chip appears after a turn and opens the breakdown popover", async ({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Chip shows the session total (1k + 200 + 8k + 800 = 10k).
|
||||
// Default: no bar (owner ask 2026-07-30) — the chip states the session total
|
||||
// (1k + 200 + 8k + 800 = 10k). The bar is opt-in via Settings.
|
||||
const chip = page.getByTestId("usage-chip");
|
||||
await expect(chip).toContainText("10k");
|
||||
|
||||
@@ -58,9 +59,41 @@ test("usage resets on a new session", async ({ page }) => {
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
await box.fill("hello");
|
||||
await box.press("Enter");
|
||||
await expect(page.getByTestId("usage-chip")).toContainText("10k", { timeout: 10_000 });
|
||||
await expect(page.getByTestId("usage-chip")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// "+ New session" wipes the transcript — and the usage accumulation with it.
|
||||
await page.getByRole("button", { name: /New session/ }).first().click();
|
||||
await expect(page.getByTestId("usage-chip")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Settings toggle turns the context bar on; default is the session total", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByText("Draft the launch note").first().click();
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
await box.fill("hello");
|
||||
await box.press("Enter");
|
||||
const chip = page.getByTestId("usage-chip");
|
||||
await expect(chip).toContainText("10k", { timeout: 10_000 }); // default: total, no bar
|
||||
|
||||
// Turn the bar ON in Settings -> General.
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expect(page.getByTestId("context-bar-toggle")).not.toBeChecked();
|
||||
const [req] = await Promise.all([
|
||||
page.waitForRequest(
|
||||
(r) => r.url().endsWith("/v1/settings/context-bar") && r.method() === "POST",
|
||||
),
|
||||
page.getByTestId("context-bar-toggle").check(),
|
||||
]);
|
||||
expect(req.postDataJSON()).toEqual({ context_bar: true });
|
||||
|
||||
// Reload so the app re-reads settings: the chip is now the fill bar, not a number.
|
||||
await page.goto("/");
|
||||
await page.getByText("Draft the launch note").first().click();
|
||||
await page.getByPlaceholder(/Ask the coworker/).fill("hello");
|
||||
await page.getByPlaceholder(/Ask the coworker/).press("Enter");
|
||||
const bar = page.getByTestId("usage-chip");
|
||||
await expect(bar).toBeVisible({ timeout: 10_000 });
|
||||
await expect(bar).not.toContainText("10k");
|
||||
await expect(bar).toHaveAttribute("title", /Context window 5% full/);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenWorker",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"identifier": "com.openworker.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+59
-11
@@ -168,6 +168,9 @@ export function App() {
|
||||
// {full model id → context window in tokens} from the curated matrix (verified only);
|
||||
// drives the composer usage chip's context-fill meter.
|
||||
const [modelContextWindows, setModelContextWindows] = useState<Record<string, number>>({});
|
||||
// Settings: show the composer's context-window fill bar. OFF by default (owner ask),
|
||||
// so an older backend without the field also shows the session total.
|
||||
const [contextBar, setContextBar] = useState(false);
|
||||
// Per-session token usage (OPE-42): rebuilt from the transcript on session load,
|
||||
// accumulated live from assistant_message events, reset with the transcript.
|
||||
const [usage, setUsage] = useState<SessionUsage>(emptyUsage());
|
||||
@@ -175,6 +178,10 @@ export function App() {
|
||||
const [mode, setMode] = useState("interactive");
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
// Transient "Compacting context…" indicator (OPE-27): set by the `compacting` event,
|
||||
// cleared by whatever the engine emits next — the summarizer call is otherwise a
|
||||
// multi-second silent stall mid-turn.
|
||||
const [compacting, setCompacting] = useState(false);
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [streaming, setStreamingState] = useState("");
|
||||
// Ref mirror of `streaming`: the WS handler closure is built once per socket and can't read
|
||||
@@ -207,10 +214,12 @@ export function App() {
|
||||
const [scheduledOpenId, setScheduledOpenId] = useState<string | null>(null);
|
||||
const [gateCreate, setGateCreate] = useState(false);
|
||||
// Which Settings section the full-page Settings surface opens on (§ Settings-as-page).
|
||||
const [settingsTab, setSettingsTab] = useState<"appearance" | "models" | "voice" | "memory" | "personas">(
|
||||
"appearance",
|
||||
);
|
||||
const openSettings = (tab: "appearance" | "models" | "voice" | "memory" | "personas" = "appearance") => {
|
||||
const [settingsTab, setSettingsTab] = useState<
|
||||
"appearance" | "models" | "skills" | "voice" | "memory" | "personas"
|
||||
>("appearance");
|
||||
const openSettings = (
|
||||
tab: "appearance" | "models" | "skills" | "voice" | "memory" | "personas" = "appearance",
|
||||
) => {
|
||||
setSettingsTab(tab);
|
||||
setSurface("settings");
|
||||
};
|
||||
@@ -505,6 +514,7 @@ export function App() {
|
||||
setModels(s.models || []);
|
||||
setModelLabels(s.model_labels || {});
|
||||
setModelContextWindows(s.model_context_windows || {});
|
||||
setContextBar(s.context_bar === true);
|
||||
setModelReady(s.model_ready);
|
||||
if (s.surfaces) setSurfaces(s.surfaces);
|
||||
})
|
||||
@@ -579,6 +589,9 @@ export function App() {
|
||||
},
|
||||
]);
|
||||
};
|
||||
// Any engine event after `compacting` means the summarizer finished (compacted /
|
||||
// silent no-op / failure prompt) — the transient must never outlive it.
|
||||
if (ev.type !== "compacting") setCompacting(false);
|
||||
switch (ev.type) {
|
||||
case "ready":
|
||||
setConnected(true);
|
||||
@@ -605,11 +618,14 @@ export function App() {
|
||||
: [...p, { kind: "connector", source: src }];
|
||||
});
|
||||
} else if (typeof d.input === "string" && d.input) {
|
||||
// `display` (force-run) is the user's literal "/name …" line; the framed
|
||||
// `input` is model-facing. Surface/dedupe on what the user actually sees.
|
||||
const shown = (typeof d.display === "string" && d.display) || (d.input as string);
|
||||
setItems((p) => {
|
||||
const last = p[p.length - 1];
|
||||
return last && last.kind === "user" && last.text === d.input
|
||||
return last && last.kind === "user" && last.text === shown
|
||||
? p
|
||||
: [...p, { kind: "user", text: d.input as string, ts: Date.now() / 1000 }];
|
||||
: [...p, { kind: "user", text: shown, ts: Date.now() / 1000 }];
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -682,6 +698,8 @@ export function App() {
|
||||
options: d.options || [],
|
||||
allow_text: d.allow_text !== false,
|
||||
multi: !!d.multi,
|
||||
header: d.header || "",
|
||||
questions: d.questions || [],
|
||||
},
|
||||
]);
|
||||
break;
|
||||
@@ -730,6 +748,14 @@ export function App() {
|
||||
]);
|
||||
announceMemoryChanged(); // Settings ▸ Memory, if open, is now stale
|
||||
break;
|
||||
case "compacting":
|
||||
setCompacting(true);
|
||||
break;
|
||||
case "compacted":
|
||||
// Auto-compaction marker (OPE-27): outbound-only — the transcript stays intact,
|
||||
// this divider just shows where the model's memory was summarized.
|
||||
setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Context compacted" }]);
|
||||
break;
|
||||
case "interrupted":
|
||||
flushPartialStream();
|
||||
setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Interrupted." }]);
|
||||
@@ -863,10 +889,13 @@ export function App() {
|
||||
return () => clearInterval(t);
|
||||
}, [surface, sessionId, browserRefreshKey, markUnattended]);
|
||||
|
||||
const send = (text: string, attachments?: Attachment[]) => {
|
||||
setItems((p) => [...p, { kind: "user", text, attachments, ts: Date.now() / 1000 }]);
|
||||
const send = (text: string, attachments?: Attachment[], skill?: string) => {
|
||||
// Force-run shows exactly what the user typed: "/name rest". Must match the server's
|
||||
// `display` sidecar formula so the turn_start dedupe recognizes the local echo.
|
||||
const shown = skill ? `/${skill}${text ? ` ${text}` : ""}` : text;
|
||||
setItems((p) => [...p, { kind: "user", text: shown, attachments, ts: Date.now() / 1000 }]);
|
||||
// The visible model rides along with the message (single source of truth per turn).
|
||||
sessionRef.current?.userMessage(text, attachments, model);
|
||||
sessionRef.current?.userMessage(text, attachments, model, skill);
|
||||
followLatest(); // sending always re-engages stream-following, wherever the user had scrolled
|
||||
};
|
||||
// Resolving a LIVE prompt also resolves its parked Inbox mirror server-side, but the polled
|
||||
@@ -1363,6 +1392,17 @@ export function App() {
|
||||
key={settingsTab}
|
||||
initialTab={settingsTab}
|
||||
onOpenPersona={(id) => openPersona(id, "settings")}
|
||||
onCreateSkill={(description) => {
|
||||
// The Skills doorway (SKILLS-SPEC §5.2): creation is a conversation. Fresh
|
||||
// session, description in the composer — the user reads and hits send. With
|
||||
// no description, the prefill invites them to finish the sentence there.
|
||||
startNewSession();
|
||||
prefillComposer(
|
||||
description
|
||||
? `Build a new skill for me: ${description}`
|
||||
: "Build a new skill for me: (describe what the skill should do)",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : surface === "audit" ? (
|
||||
<AuditView />
|
||||
@@ -1548,7 +1588,11 @@ export function App() {
|
||||
<ThinkingBlock text={reasoningStream} live />
|
||||
</div>
|
||||
)}
|
||||
{/* Compaction runs between provider turns (nothing streams during it), so
|
||||
the transient takes over the waiting slot with a specific label. */}
|
||||
{running && compacting && <WaitingForAgent label="Compacting context…" />}
|
||||
{running &&
|
||||
!compacting &&
|
||||
!reasoningStream &&
|
||||
(!streaming || streamMode(streaming, items, running) === "hold") &&
|
||||
!lastItemIsAssistant(items) && <WaitingForAgent />}
|
||||
@@ -1595,6 +1639,7 @@ export function App() {
|
||||
onInterrupt={interrupt}
|
||||
onModeChange={changeMode}
|
||||
onModelChange={changeModel}
|
||||
sessionId={sessionId}
|
||||
workspace={needsWorkspace(agent) ? workspace || "" : undefined}
|
||||
unattended={unattended}
|
||||
onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined}
|
||||
@@ -1602,6 +1647,7 @@ export function App() {
|
||||
resetKey={sessionId}
|
||||
usage={usage}
|
||||
contextWindow={modelContextWindows[model]}
|
||||
contextBar={contextBar}
|
||||
placeholder={
|
||||
agent === "code"
|
||||
? "Ask the coder to build, fix, or explain… (drop or paste files)"
|
||||
@@ -1635,6 +1681,8 @@ export function App() {
|
||||
options: pendingQuestion.options,
|
||||
allow_text: pendingQuestion.allow_text,
|
||||
multi: pendingQuestion.multi,
|
||||
header: pendingQuestion.header,
|
||||
questions: pendingQuestion.questions,
|
||||
}}
|
||||
onResolve={(_id, answer) => answerQuestion(answer)}
|
||||
compact
|
||||
@@ -1714,12 +1762,12 @@ function lastItemIsAssistant(items: Item[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function WaitingForAgent() {
|
||||
function WaitingForAgent({ label }: { label?: string }) {
|
||||
return (
|
||||
<div className="waiting-transcript">
|
||||
<div className="waiting-row" aria-live="polite">
|
||||
<span className="waiting-spinner" />
|
||||
<span>Waiting for agent...</span>
|
||||
<span>{label || "Waiting for agent..."}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+187
-4
@@ -1,4 +1,4 @@
|
||||
import type { SessionInfo, WsEvent } from "./types";
|
||||
import type { GroupedQuestion, QuestionOption, SessionInfo, WsEvent } from "./types";
|
||||
|
||||
declare const __COWORKER_DEV_TOKEN__: string;
|
||||
|
||||
@@ -195,6 +195,8 @@ export interface ArtifactContent {
|
||||
content?: string;
|
||||
data_url?: string;
|
||||
truncated?: boolean;
|
||||
// kind === "folder": a directory listing (models sometimes link a whole package dir).
|
||||
entries?: { name: string; dir: boolean; size: number }[];
|
||||
}
|
||||
|
||||
export async function getArtifacts(sessionId: string): Promise<ArtifactInfo[]> {
|
||||
@@ -692,6 +694,9 @@ export interface ModelSettings {
|
||||
nav_layout?: "flat" | "grouped";
|
||||
// Sidebar: sessions shown per group before "Show more" (default 5, 1–50).
|
||||
sessions_peek?: number;
|
||||
// Composer: show the context-window fill bar (default FALSE; absent → the chip shows
|
||||
// the session total). The usage popover keeps both numbers regardless.
|
||||
context_bar?: boolean;
|
||||
// Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent.
|
||||
model_labels?: Record<string, string>;
|
||||
// {full id → context window in tokens}, verified matrix entries only — drives the
|
||||
@@ -702,6 +707,12 @@ export interface ModelSettings {
|
||||
pdf_fallback?: "text" | "images";
|
||||
pdf_max_pages?: number; // default 20, 1–100
|
||||
pdf_max_mb?: number; // default 10, 1–10
|
||||
// Auto-compaction of long histories (OPE-27): trigger = min(threshold% × context
|
||||
// window, cap tokens); model pins the summarizer ("" → the session's own model).
|
||||
// Optional so the GUI is robust to an older backend.
|
||||
compaction_threshold_pct?: number; // default 0.8, 0.10–0.95
|
||||
compaction_cap_tokens?: number; // default 250000
|
||||
compaction_model?: string;
|
||||
}
|
||||
|
||||
export interface PdfSettings {
|
||||
@@ -722,6 +733,24 @@ export async function setPdfSettings(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface CompactionSettings {
|
||||
compaction_threshold_pct: number;
|
||||
compaction_cap_tokens: number;
|
||||
compaction_model: string;
|
||||
}
|
||||
|
||||
/** Persist the auto-compaction overrides (threshold %, token cap, summarizer model). */
|
||||
export async function setCompactionSettings(
|
||||
patch: Partial<CompactionSettings>,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const res = await fetch(`${httpBase()}/v1/settings/compaction`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Local page/size probe for a PDF data URL — the composer's attach-time threshold check. */
|
||||
export async function inspectPdf(
|
||||
dataUrl: string,
|
||||
@@ -734,6 +763,18 @@ export async function inspectPdf(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Persist whether the composer shows the context-window fill bar. */
|
||||
export async function setContextBar(
|
||||
shown: boolean,
|
||||
): Promise<{ ok: boolean; context_bar?: boolean; error?: string }> {
|
||||
const res = await fetch(`${httpBase()}/v1/settings/context-bar`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ context_bar: shown }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Persist how many sessions a sidebar group shows before "Show more". */
|
||||
export async function setSessionsPeek(
|
||||
n: number,
|
||||
@@ -1050,6 +1091,141 @@ export async function setSessionConnection(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// -- Skills (SKILLS-SPEC §4) ----------------------------------------------------
|
||||
// Scope = folder location: "global" (every session) or "project" (one workspace).
|
||||
// The session endpoints resolve the effective menu (Settings disables + session mutes).
|
||||
|
||||
export interface SkillRow {
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
scope: "global" | "project";
|
||||
source: string; // "local" | "uploaded"
|
||||
enabled: boolean;
|
||||
path: string;
|
||||
files?: number; // bundled resources beyond SKILL.md (§6 — rich skills are visible)
|
||||
}
|
||||
|
||||
export interface SessionSkillRow {
|
||||
name: string;
|
||||
description: string;
|
||||
scope: "global" | "project";
|
||||
enabled: boolean; // false = muted for this session only
|
||||
}
|
||||
|
||||
export interface SkillUploadPreview {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
token?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
const skillUrl = (path = "") => `${httpBase()}/v1/skills${path}`;
|
||||
const jsonPost = (body: unknown, method = "POST") => ({
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
export async function listSkills(workspace?: string): Promise<SkillRow[]> {
|
||||
const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
|
||||
const res = await fetch(skillUrl(qs));
|
||||
return (await res.json()).skills ?? [];
|
||||
}
|
||||
|
||||
export async function createSkill(body: {
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
scope?: "global" | "project";
|
||||
workspace?: string;
|
||||
}): Promise<{ ok: boolean; error?: string }> {
|
||||
const res = await fetch(skillUrl(), jsonPost(body));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateSkill(
|
||||
name: string,
|
||||
patch: { description?: string; instructions?: string; enabled?: boolean; workspace?: string },
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}`), jsonPost(patch, "PATCH"));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function revealSkill(name: string): Promise<{ ok: boolean; error?: string }> {
|
||||
// §6 "Show folder": the backend opens the skill's folder in the OS file manager.
|
||||
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}/reveal`), jsonPost({}));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteSkill(
|
||||
name: string,
|
||||
workspace?: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
|
||||
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}${qs}`), { method: "DELETE" });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function moveSkill(
|
||||
name: string,
|
||||
scope: "global" | "project",
|
||||
workspace?: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const res = await fetch(skillUrl(`/${encodeURIComponent(name)}/move`), jsonPost({ scope, workspace }));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function stageSkillUpload(
|
||||
dataB64: string,
|
||||
filename = "",
|
||||
): Promise<SkillUploadPreview> {
|
||||
const res = await fetch(skillUrl("/upload"), jsonPost({ data_b64: dataB64, filename }));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function confirmSkillUpload(
|
||||
token: string,
|
||||
scope: "global" | "project" = "global",
|
||||
workspace?: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const res = await fetch(skillUrl("/upload/confirm"), jsonPost({ token, scope, workspace }));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
export async function sessionSkills(
|
||||
sessionId: string,
|
||||
workspace?: string,
|
||||
): Promise<SessionSkillRow[]> {
|
||||
const qs = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
|
||||
const res = await fetch(
|
||||
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/skills${qs}`,
|
||||
);
|
||||
return (await res.json()).skills ?? [];
|
||||
}
|
||||
|
||||
export async function setSessionSkill(
|
||||
sessionId: string,
|
||||
skill: string,
|
||||
enabled: boolean,
|
||||
opts: { clear?: boolean; workspace?: string } = {},
|
||||
): Promise<{ skills?: SessionSkillRow[]; ok?: boolean; error?: string }> {
|
||||
const res = await fetch(
|
||||
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/skills`,
|
||||
jsonPost({
|
||||
skill,
|
||||
enabled,
|
||||
...(opts.clear ? { clear: true } : {}),
|
||||
...(opts.workspace ? { workspace: opts.workspace } : {}),
|
||||
}),
|
||||
);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// -- Inbox + Unattended -------------------------------------------------------
|
||||
export interface InboxItem {
|
||||
id: string;
|
||||
@@ -1063,10 +1239,14 @@ export interface InboxItem {
|
||||
created_at: string;
|
||||
resolved_at: string | null;
|
||||
visibility?: "inline" | "inbox";
|
||||
// Question metadata (ask_user): quick-reply choices + a free-text escape.
|
||||
options?: string[];
|
||||
// Question metadata (ask_user): quick-reply choices + a free-text escape. Options may be rich
|
||||
// {label, description, recommended, preview} objects (OPE-51); `questions` is the grouped form
|
||||
// (stepper), whose resolution is a JSON object string keyed by header-or-question.
|
||||
options?: QuestionOption[];
|
||||
allow_text?: boolean;
|
||||
multi?: boolean;
|
||||
header?: string;
|
||||
questions?: GroupedQuestion[];
|
||||
// Kind-specific payload (directory: {path, writable}; …).
|
||||
data?: Record<string, any>;
|
||||
// Originating-session context (server-joined) so the Inbox is self-contained.
|
||||
@@ -1874,12 +2054,15 @@ export class Session {
|
||||
* exactly what the user sees — immune to set_model races across reconnects (a new cowork
|
||||
* session always reconnects once to adopt its scratch dir, which could drop a queued
|
||||
* set_model and leave the engine on a stale/resumed model; found 2026-07-04). */
|
||||
userMessage(text: string, attachments?: unknown[], model?: string) {
|
||||
userMessage(text: string, attachments?: unknown[], model?: string, skill?: string) {
|
||||
this.send({
|
||||
type: "user_message",
|
||||
text,
|
||||
...(model ? { model } : {}),
|
||||
...(attachments?.length ? { attachments } : {}),
|
||||
// Force-run (SKILLS-SPEC §4.1): the composer's /skill pick rides as its own field;
|
||||
// the server validates it against the session's effective menu and frames the turn.
|
||||
...(skill ? { skill } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ export function AccessSection({
|
||||
: roots.length > 0
|
||||
? `${roots.length} folder${roots.length === 1 ? "" : "s"}`
|
||||
: null;
|
||||
const summary = folderPart ? `${sourcesPart} · ${folderPart}` : sourcesPart;
|
||||
const summary = [sourcesPart, folderPart].filter(Boolean).join(" · ");
|
||||
|
||||
return (
|
||||
<section className="rail-section" ref={rootEl} data-testid="access-section">
|
||||
@@ -383,6 +383,14 @@ export function AccessSection({
|
||||
+ Add a source…
|
||||
</button>
|
||||
)}
|
||||
{/* Lives with its list (tester ask 2026-07-26): each group's manage link sits
|
||||
directly under that group, not pooled at the section's bottom. */}
|
||||
<button
|
||||
className="mt-1.5 block text-[12px] text-accent font-medium hover:underline text-left"
|
||||
onClick={() => onOpenIntegrations?.()}
|
||||
>
|
||||
Manage all connectors (global) →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{recommended.length > 0 && (
|
||||
@@ -456,13 +464,6 @@ export function AccessSection({
|
||||
)}
|
||||
{rootsError && <div className="roots-err">{rootsError}</div>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="text-[12px] text-accent font-medium hover:underline text-left"
|
||||
onClick={() => onOpenIntegrations?.()}
|
||||
>
|
||||
Manage all connectors (global) →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -110,7 +110,7 @@ describe("ApprovalCard — §35 shapes", () => {
|
||||
expect(onApprove).toHaveBeenCalledWith("once");
|
||||
});
|
||||
|
||||
it("send_file gets the full external card: destination title, file chip, leaves-the-Mac note", () => {
|
||||
it("send_file gets the full external card: destination title, file chip, leaves-the-computer note", () => {
|
||||
render(
|
||||
<ApprovalCard
|
||||
item={sendApproval({
|
||||
@@ -121,7 +121,7 @@ describe("ApprovalCard — §35 shapes", () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Send a file to/).textContent).toContain("C9");
|
||||
expect(screen.getByText(/leaves this Mac → Slack/)).toBeTruthy();
|
||||
expect(screen.getByText(/leaves this computer → Slack/)).toBeTruthy();
|
||||
expect(screen.getByText(/report\.pdf/)).toBeTruthy();
|
||||
expect(screen.getByText(/here you go/)).toBeTruthy();
|
||||
expect(screen.getByText("Allow once")).toBeTruthy();
|
||||
@@ -159,7 +159,7 @@ describe("ApprovalCard — §35 shapes", () => {
|
||||
);
|
||||
expect(screen.getByText(/Run a command — fetch semiconductor stock data/)).toBeTruthy();
|
||||
expect(screen.getByText(/python3 fetch\.py/)).toBeTruthy();
|
||||
expect(screen.getByText(/stays on this Mac/)).toBeTruthy();
|
||||
expect(screen.getByText(/stays on this computer/)).toBeTruthy();
|
||||
expect(screen.getByText("Always allow this command")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -213,10 +213,94 @@ describe("InboxItemCard — Allow every time on parked run approvals", () => {
|
||||
expect(screen.getByText("fetch_data.py")).toBeTruthy();
|
||||
expect(screen.queryByText("Run `send_message`?")).toBeNull();
|
||||
expect(screen.getByText(/import json/)).toBeTruthy();
|
||||
expect(screen.getByText(/stays on this Mac/)).toBeTruthy();
|
||||
expect(screen.getByText(/stays on this computer/)).toBeTruthy();
|
||||
// §35 labels; resolution vocabulary unchanged (works on every approver path).
|
||||
fireEvent.click(screen.getByText("Allow once"));
|
||||
expect(onResolve).toHaveBeenCalledWith("i1", "allow");
|
||||
// Old rows without tool data keep the legacy treatment (covered above).
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApprovalCard — save_skill (SKILLS-SPEC §5.2)", () => {
|
||||
const skillApproval = (extra: Partial<ApprovalItem> = {}): ApprovalItem =>
|
||||
sendApproval({
|
||||
name: "save_skill",
|
||||
category: "skills",
|
||||
args: {
|
||||
name: "weekly-github-report",
|
||||
description: "Create a concise Monday status report from GitHub activity.",
|
||||
instructions: "1. Fetch PRs\n2. Write the report",
|
||||
files: ["fetch_prs.py", "sub/example-report.md"],
|
||||
},
|
||||
standingTarget: undefined,
|
||||
...extra,
|
||||
});
|
||||
|
||||
it("shows name-first title, description, instructions, and every bundled file", () => {
|
||||
render(<ApprovalCard item={skillApproval()} onApprove={vi.fn()} />);
|
||||
expect(screen.getByText("weekly-github-report")).toBeTruthy(); // bold obj in the title
|
||||
expect(screen.getAllByText(/to your skills/).length).toBeGreaterThan(0); // title + footer
|
||||
// The corner answers WHERE; the footer answers what approving means (§5.2 review round).
|
||||
expect(screen.getByText("saves to Settings ▸ Skills")).toBeTruthy();
|
||||
expect(screen.getByText(/usable in every conversation from\s+then on/)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("Create a concise Monday status report from GitHub activity."),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/Fetch PRs/)).toBeTruthy();
|
||||
const chips = screen.getByTestId("skill-bundle-files");
|
||||
expect(chips.textContent).toContain("fetch_prs.py");
|
||||
expect(chips.textContent).toContain("example-report.md"); // basename, not the path
|
||||
});
|
||||
|
||||
it("uses the §7 button copy and never offers a session-wide always", () => {
|
||||
const onApprove = vi.fn();
|
||||
render(<ApprovalCard item={skillApproval()} onApprove={onApprove} />);
|
||||
expect(screen.queryByText("Always allow")).toBeNull(); // every proposal gets its own review
|
||||
expect(screen.queryByText("Deny")).toBeNull();
|
||||
fireEvent.click(screen.getByText("Add to my skills"));
|
||||
expect(onApprove).toHaveBeenCalledWith("once");
|
||||
fireEvent.click(screen.getByText("Not now"));
|
||||
expect(onApprove).toHaveBeenCalledWith("deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InboxItemCard — parked save_skill proposals (SKILLS-SPEC §5.2)", () => {
|
||||
const parked = (): InboxItem => ({
|
||||
id: "i9",
|
||||
session_id: "s1",
|
||||
kind: "approval",
|
||||
title: "Run `save_skill`?",
|
||||
body: "",
|
||||
state: "pending",
|
||||
resolution: null,
|
||||
inbox: "default",
|
||||
created_at: "",
|
||||
resolved_at: null,
|
||||
data: {
|
||||
tool: "save_skill",
|
||||
arguments: {
|
||||
name: "weekly-github-report",
|
||||
description: "Create a concise Monday status report from GitHub activity.",
|
||||
instructions: "1. Fetch PRs\n2. Write the report",
|
||||
files: ["fetch_prs.py"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it("wears the same review surface and button copy as the live card", () => {
|
||||
const onResolve = vi.fn();
|
||||
render(<InboxItemCard item={parked()} onResolve={onResolve} />);
|
||||
expect(screen.getByText("saves to Settings ▸ Skills")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("Create a concise Monday status report from GitHub activity."),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/Fetch PRs/)).toBeTruthy();
|
||||
expect(screen.getByTestId("skill-bundle-files").textContent).toContain("fetch_prs.py");
|
||||
expect(screen.getByText(/usable in every conversation/)).toBeTruthy();
|
||||
expect(screen.queryByText("Allow once")).toBeNull();
|
||||
fireEvent.click(screen.getByText("Add to my skills"));
|
||||
expect(onResolve).toHaveBeenCalledWith("i9", "allow");
|
||||
fireEvent.click(screen.getByText("Not now"));
|
||||
expect(onResolve).toHaveBeenCalledWith("i9", "deny");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,43 @@ const EXTERNAL = new Set(["send_message", "send_file"]);
|
||||
|
||||
type ApprovalItem = Extract<Item, { kind: "approval" }>;
|
||||
|
||||
// Per-tool button copy (§7): a skill proposal is an "add", not an "allow". Shared with the
|
||||
// parked Inbox card so both dialects match.
|
||||
export function approvalActionLabels(name?: string): { allow: string; deny: string } {
|
||||
return name === "save_skill"
|
||||
? { allow: "Add to my skills", deny: "Not now" }
|
||||
: { allow: "Allow once", deny: "Deny" };
|
||||
}
|
||||
|
||||
// save_skill's review surface (SKILLS-SPEC §5.2): description, the full instructions
|
||||
// (clamped, expandable, scrollable), every bundled file, and the guaranteed footer that
|
||||
// answers "added WHERE, available WHEN". Shared verbatim with the parked Inbox card —
|
||||
// one decision, one dialect.
|
||||
export function SaveSkillPreview({ args }: { args: any }) {
|
||||
return (
|
||||
<>
|
||||
{args?.description && <div className="approval-with">{String(args.description)}</div>}
|
||||
{args?.instructions && <PreviewBlock text={String(args.instructions)} mono={false} />}
|
||||
{Array.isArray(args?.files) && args.files.length > 0 && (
|
||||
<div data-testid="skill-bundle-files">
|
||||
{args.files.map((f: unknown, i: number) => (
|
||||
<span className="approval-filechip" key={i}>
|
||||
<span className="ico">
|
||||
<Icon name="file" size={13} />
|
||||
</span>
|
||||
{String(f).split(/[\\/]/).pop() || String(f)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="approval-with">
|
||||
Approving adds it to your skills on this computer — usable in every conversation from
|
||||
then on.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// A `permissions` proposal on the create_scheduled_task consent card (§25): reads are
|
||||
// disclosure lines, writes are the standing grants the approval mints.
|
||||
interface PermissionLine {
|
||||
@@ -65,14 +102,17 @@ export function scopeNote(
|
||||
args: any,
|
||||
category?: string,
|
||||
): { text: string; external: boolean } {
|
||||
// save_skill's corner answers WHERE (SKILLS-SPEC §5.2): the exact place to find, edit,
|
||||
// or turn off the skill afterwards.
|
||||
if (name === "save_skill") return { text: "saves to Settings ▸ Skills", external: false };
|
||||
if (category === "connector") return { text: "acts on a connected service", external: true };
|
||||
if (EXTERNAL.has(name)) {
|
||||
const platform = String(args?.target ?? "").split(":")[0];
|
||||
const names: Record<string, string> = { slack: "Slack", telegram: "Telegram" };
|
||||
return { text: `leaves this Mac → ${names[platform] || platform || "a connected chat"}`, external: true };
|
||||
return { text: `leaves this computer → ${names[platform] || platform || "a connected chat"}`, external: true };
|
||||
}
|
||||
const overwrite = name === "write_file" && args?.overwrite;
|
||||
return { text: "stays on this Mac" + (overwrite ? " · overwrites the existing file" : ""), external: false };
|
||||
return { text: "stays on this computer" + (overwrite ? " · overwrites the existing file" : ""), external: false };
|
||||
}
|
||||
|
||||
// The proposed content/command, straight from the tool call's ARGS — the file/action
|
||||
@@ -125,11 +165,13 @@ function Buttons({
|
||||
onApprove,
|
||||
runTask,
|
||||
primaryLabel,
|
||||
denyLabel = "Deny",
|
||||
}: {
|
||||
item: ApprovalItem;
|
||||
onApprove: (decision: ApprovalDecision) => void;
|
||||
runTask?: { id: string; title: string } | null;
|
||||
primaryLabel: string;
|
||||
denyLabel?: string;
|
||||
}) {
|
||||
const connector = item.category === "connector";
|
||||
const offerStanding = !!(runTask && item.standingTarget);
|
||||
@@ -152,7 +194,9 @@ function Buttons({
|
||||
exactly the scope distinction §25 exists to draw. Same rule for run_shell:
|
||||
the command-scoped button below is the specific (safer) grant, so the
|
||||
tool-wide one stays out of the card. */}
|
||||
{!connector && !offerStanding && item.name !== "run_shell" && (
|
||||
{/* save_skill: no session-wide "always" — every skill proposal gets its own review
|
||||
(SKILLS-SPEC §5: one gate, always). */}
|
||||
{!connector && !offerStanding && item.name !== "run_shell" && item.name !== "save_skill" && (
|
||||
<button
|
||||
className="btn"
|
||||
title={`Always allow ${TOOL_VERBS[item.name]?.toLowerCase() || item.name} for this session`}
|
||||
@@ -168,7 +212,7 @@ function Buttons({
|
||||
)}
|
||||
<span className="spacer" />
|
||||
<button className="btn quiet-deny" onClick={() => onApprove("deny")}>
|
||||
Deny
|
||||
{denyLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -252,6 +296,8 @@ export function ApprovalCard({
|
||||
{item.name === "send_message" && item.args?.text && (
|
||||
<MessagePreview text={String(item.args.text)} />
|
||||
)}
|
||||
{/* save_skill (SKILLS-SPEC §5.2): the arguments ARE the review surface. */}
|
||||
{item.name === "save_skill" && <SaveSkillPreview args={item.args} />}
|
||||
|
||||
{grants.length > 0 && (
|
||||
<div className="approval-grants" data-testid="approval-grants">
|
||||
@@ -272,7 +318,7 @@ export function ApprovalCard({
|
||||
)}
|
||||
{/* Long-tail tools: no bespoke preview — fall back to the compact args line. */}
|
||||
{!FILE_WRITES.has(item.name) &&
|
||||
!["run_shell", "send_message", "send_file"].includes(item.name) &&
|
||||
!["run_shell", "send_message", "send_file", "save_skill"].includes(item.name) &&
|
||||
!grants.length &&
|
||||
shortArgs(item.args) && <div className="approval-rest">{shortArgs(item.args)}</div>}
|
||||
{reason && <div className="approval-reason">{reason}</div>}
|
||||
@@ -280,7 +326,13 @@ export function ApprovalCard({
|
||||
{item.resolved ? (
|
||||
<div className="resolved">Approved: {item.resolved.replace("_", " ")}</div>
|
||||
) : (
|
||||
<Buttons item={item} onApprove={onApprove} runTask={runTask} primaryLabel="Allow once" />
|
||||
<Buttons
|
||||
item={item}
|
||||
onApprove={onApprove}
|
||||
runTask={runTask}
|
||||
primaryLabel={approvalActionLabels(item.name).allow}
|
||||
denyLabel={approvalActionLabels(item.name).deny}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -433,7 +433,7 @@ export function AutomationQuickstart({
|
||||
<span className="block text-[13px] text-ink font-medium">
|
||||
One sign-in unlocks every one-click connection
|
||||
</span>
|
||||
Connections are brokered by OpenWorker Cloud — your tokens stay on this Mac.
|
||||
Connections are brokered by OpenWorker Cloud — your tokens stay on this computer.
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
{signinPhase ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// SKILLS-SPEC §4.6 GUI — the composer's "/" force-run popup: opens only for a leading
|
||||
// slash, lists only the session's effective (enabled) menu, filters while typing, and the
|
||||
// picked skill rides onSend as its own field — never as message text.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { Composer } from "./Composer";
|
||||
|
||||
const MENU = {
|
||||
skills: [
|
||||
{ name: "weekly-report", description: "Monday status report", scope: "global", enabled: true },
|
||||
{ name: "greet", description: "says hello", scope: "project", enabled: true },
|
||||
{ name: "muted-one", description: "muted here", scope: "global", enabled: false },
|
||||
],
|
||||
};
|
||||
|
||||
function stubFetch() {
|
||||
const calls: { url: string; method: string }[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, method: (init?.method || "GET").toUpperCase() });
|
||||
if (url.includes("/skills")) return { ok: true, json: async () => MENU } as Response;
|
||||
return { ok: true, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
return calls;
|
||||
}
|
||||
|
||||
const props = (extra: Partial<Parameters<typeof Composer>[0]> = {}) => ({
|
||||
mode: "interactive",
|
||||
model: "gpt-5.6-sol",
|
||||
running: false,
|
||||
connected: true,
|
||||
sessionId: "s1",
|
||||
onSend: vi.fn(),
|
||||
onInterrupt: vi.fn(),
|
||||
onModeChange: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
...extra,
|
||||
});
|
||||
|
||||
const box = () => screen.getByPlaceholderText(/Ask the coworker/);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Composer / skills popup", () => {
|
||||
it("opens on a leading '/' and lists only enabled skills from the effective menu", async () => {
|
||||
stubFetch();
|
||||
render(<Composer {...props()} />);
|
||||
fireEvent.change(box(), { target: { value: "/" } });
|
||||
await screen.findByTestId("skill-popup");
|
||||
expect(await screen.findByText("/weekly-report")).toBeTruthy();
|
||||
expect(screen.getByText("/greet")).toBeTruthy();
|
||||
expect(screen.queryByText("/muted-one")).toBeNull(); // muted → not offered
|
||||
expect(screen.getByText("project")).toBeTruthy(); // scope badge
|
||||
});
|
||||
|
||||
it("filters as you type", async () => {
|
||||
stubFetch();
|
||||
render(<Composer {...props()} />);
|
||||
fireEvent.change(box(), { target: { value: "/" } });
|
||||
await screen.findByText("/weekly-report");
|
||||
fireEvent.change(box(), { target: { value: "/wee" } });
|
||||
expect(screen.getByText("/weekly-report")).toBeTruthy();
|
||||
expect(screen.queryByText("/greet")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT open for a mid-text slash", async () => {
|
||||
stubFetch();
|
||||
render(<Composer {...props()} />);
|
||||
fireEvent.change(box(), { target: { value: "rate 5/10 please" } });
|
||||
expect(screen.queryByTestId("skill-popup")).toBeNull();
|
||||
});
|
||||
|
||||
it("selecting inserts /name inline; the send strips the prefix and carries the skill field", async () => {
|
||||
stubFetch();
|
||||
const p = props();
|
||||
render(<Composer {...p} />);
|
||||
fireEvent.change(box(), { target: { value: "/gr" } });
|
||||
fireEvent.click(await screen.findByRole("option", { name: /greet/ }));
|
||||
expect((box() as HTMLTextAreaElement).value).toBe("/greet "); // inline, no chip
|
||||
fireEvent.change(box(), { target: { value: "/greet say hi to the team" } });
|
||||
fireEvent.keyDown(box(), { key: "Enter" });
|
||||
await waitFor(() => expect(p.onSend).toHaveBeenCalled());
|
||||
expect(p.onSend).toHaveBeenCalledWith("say hi to the team", [], "greet");
|
||||
});
|
||||
|
||||
it("a skill-only send works and Enter inside the popup never sends the query text", async () => {
|
||||
stubFetch();
|
||||
const p = props();
|
||||
render(<Composer {...p} />);
|
||||
fireEvent.change(box(), { target: { value: "/wee" } });
|
||||
await screen.findByText("/weekly-report");
|
||||
fireEvent.keyDown(box(), { key: "Enter" }); // selects, does not send
|
||||
expect(p.onSend).not.toHaveBeenCalled();
|
||||
expect((box() as HTMLTextAreaElement).value).toBe("/weekly-report ");
|
||||
fireEvent.keyDown(box(), { key: "Enter" }); // now sends, skill-only
|
||||
await waitFor(() => expect(p.onSend).toHaveBeenCalledWith("", [], "weekly-report"));
|
||||
});
|
||||
|
||||
it("editing the /name prefix away un-picks the skill", async () => {
|
||||
stubFetch();
|
||||
const p = props();
|
||||
render(<Composer {...p} />);
|
||||
fireEvent.change(box(), { target: { value: "/gr" } });
|
||||
fireEvent.click(await screen.findByRole("option", { name: /greet/ }));
|
||||
fireEvent.change(box(), { target: { value: "hello plain" } }); // prefix gone
|
||||
fireEvent.keyDown(box(), { key: "Enter" });
|
||||
await waitFor(() => expect(p.onSend).toHaveBeenCalledWith("hello plain", [], undefined));
|
||||
});
|
||||
|
||||
it("Escape closes the popup and no popup ever opens without a sessionId", async () => {
|
||||
stubFetch();
|
||||
render(<Composer {...props()} />);
|
||||
fireEvent.change(box(), { target: { value: "/gr" } });
|
||||
await screen.findByTestId("skill-popup");
|
||||
fireEvent.keyDown(box(), { key: "Escape" });
|
||||
expect(screen.queryByTestId("skill-popup")).toBeNull();
|
||||
cleanup();
|
||||
stubFetch();
|
||||
render(<Composer {...props({ sessionId: undefined })} />);
|
||||
fireEvent.change(box(), { target: { value: "/" } });
|
||||
expect(screen.queryByTestId("skill-popup")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer — the doorway prefill (SKILLS-SPEC §5.2)", () => {
|
||||
it("a prefill arriving together with a session switch survives the draft clear", async () => {
|
||||
stubFetch();
|
||||
const { rerender } = render(<Composer {...props({ resetKey: "s1" })} />);
|
||||
// The doorway does both in one render: new session (resetKey) + prefill. The clear
|
||||
// effect must run BEFORE the prefill effect or the prefill is wiped (regression).
|
||||
rerender(
|
||||
<Composer
|
||||
{...props({
|
||||
resetKey: "s2",
|
||||
prefill: { text: "Build a new skill for me: release procedure", nonce: 1 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect((box() as HTMLTextAreaElement).value).toBe(
|
||||
"Build a new skill for me: release procedure",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import type { Attachment, SessionUsage } from "../types";
|
||||
import { isPdfFile, readFile } from "../attach";
|
||||
import { getSettings, inspectPdf } from "../api";
|
||||
import { getSettings, inspectPdf, sessionSkills, type SessionSkillRow } from "../api";
|
||||
import { formatTokens, totalTokens } from "../usage";
|
||||
import { Dropdown, type Option } from "./Dropdown";
|
||||
import { Icon } from "./Icon";
|
||||
@@ -59,7 +59,10 @@ interface Props {
|
||||
modelReady?: boolean;
|
||||
onConnectModel?: () => void;
|
||||
onConfigureVoiceInput?: () => void;
|
||||
onSend: (text: string, attachments?: Attachment[]) => void;
|
||||
onSend: (text: string, attachments?: Attachment[], skill?: string) => void;
|
||||
// Feeds the "/" force-run popup (SKILLS-SPEC §4.1 #3): the popup lists this session's
|
||||
// effective skill menu. Absent (e.g. tests without sessions) → the popup never opens.
|
||||
sessionId?: string;
|
||||
onInterrupt: () => void;
|
||||
onModeChange: (mode: string) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
@@ -84,11 +87,53 @@ interface Props {
|
||||
// Context-window size (tokens) of the ACTIVE model, from the curated matrix;
|
||||
// undefined hides the fill meter (unverified/custom models) but keeps the counts.
|
||||
contextWindow?: number;
|
||||
// Settings toggle (default off): true shows the fill bar instead of the session total.
|
||||
contextBar?: boolean;
|
||||
}
|
||||
|
||||
export function Composer(props: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
// "/" force-run (SKILLS-SPEC §4.1 #3). The popup derives from the draft: it is open while
|
||||
// the text is a bare "/query" (no whitespace yet) and no skill is picked. Selecting a row
|
||||
// inserts "/name " INLINE in the box (Claude-Code style — the slash text IS the state);
|
||||
// the user keeps typing after it, and on send the prefix is stripped while the skill name
|
||||
// rides the user_message as its own field. Editing the prefix away un-picks the skill.
|
||||
const [pendingSkill, setPendingSkill] = useState<SessionSkillRow | null>(null);
|
||||
const [slashSkills, setSlashSkills] = useState<SessionSkillRow[] | null>(null);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
const prefixIntact =
|
||||
pendingSkill !== null &&
|
||||
(text === `/${pendingSkill.name}` || text.startsWith(`/${pendingSkill.name} `));
|
||||
useEffect(() => {
|
||||
if (pendingSkill && !prefixIntact) setPendingSkill(null);
|
||||
}, [pendingSkill, prefixIntact]);
|
||||
const slashQuery =
|
||||
!prefixIntact && props.sessionId && text.startsWith("/") && !/\s/.test(text.slice(1))
|
||||
? text.slice(1).toLowerCase()
|
||||
: null;
|
||||
const slashMatches = (slashSkills ?? []).filter((s) =>
|
||||
s.name.toLowerCase().includes(slashQuery ?? ""),
|
||||
);
|
||||
useEffect(() => {
|
||||
// Fetch on each popup open (fresh menu); drop when closed.
|
||||
if (slashQuery === null) {
|
||||
setSlashSkills(null);
|
||||
setSlashIndex(0);
|
||||
return;
|
||||
}
|
||||
if (slashSkills === null && props.sessionId) {
|
||||
sessionSkills(props.sessionId, props.workspace)
|
||||
.then((all) => setSlashSkills(all.filter((s) => s.enabled)))
|
||||
.catch(() => setSlashSkills([]));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slashQuery === null]);
|
||||
const pickSkill = (s: SessionSkillRow) => {
|
||||
setPendingSkill(s);
|
||||
setText(`/${s.name} `);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [attachMenuOpen, setAttachMenuOpen] = useState(false);
|
||||
const [dictation, setDictation] = useState<DictationStatus | null>(null);
|
||||
@@ -117,6 +162,17 @@ export function Composer(props: Props) {
|
||||
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
|
||||
}, [text]);
|
||||
|
||||
// Clear the draft when the conversation changes, so a half-typed message / picked file doesn't
|
||||
// bleed from one session into another. Declared BEFORE the prefill effect: when both fire in
|
||||
// the same render (the Skills doorway starts a new session AND prefills it), effects run in
|
||||
// declaration order — clear first, then the prefill lands on the fresh session.
|
||||
useEffect(() => {
|
||||
setText("");
|
||||
setAttachments([]);
|
||||
setPendingSkill(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.resetKey]);
|
||||
|
||||
// Apply a prefill (text + attachments) pushed from outside, then focus the composer. Applied at
|
||||
// most once per nonce (a ref guards against StrictMode/re-render double-fires), and attachments
|
||||
// are de-duplicated so the same file never lands twice.
|
||||
@@ -131,14 +187,6 @@ export function Composer(props: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.prefill?.nonce]);
|
||||
|
||||
// Clear the draft when the conversation changes, so a half-typed message / picked file doesn't
|
||||
// bleed from one session into another.
|
||||
useEffect(() => {
|
||||
setText("");
|
||||
setAttachments([]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.resetKey]);
|
||||
|
||||
// Dictation is intentionally native-only: the browser/dev build remains a local server client
|
||||
// and never turns on the browser microphone or ships audio anywhere.
|
||||
useEffect(() => {
|
||||
@@ -262,19 +310,54 @@ export function Composer(props: Props) {
|
||||
const needsModel = props.modelReady === false;
|
||||
|
||||
const submit = () => {
|
||||
const t = text.trim();
|
||||
if ((!t && attachments.length === 0) || props.running || dictation?.recording || dictationBusy) return;
|
||||
// While the "/" popup is open the draft is a query, not a message — never send it.
|
||||
if (slashQuery !== null) return;
|
||||
// The visible "/name " prefix is UI state, not message text — strip it for the send;
|
||||
// the skill rides as its own field.
|
||||
const skill = prefixIntact ? pendingSkill!.name : undefined;
|
||||
const t = (skill ? text.slice(skill.length + 1) : text).trim();
|
||||
if (
|
||||
(!t && attachments.length === 0 && !skill) ||
|
||||
props.running ||
|
||||
dictation?.recording ||
|
||||
dictationBusy
|
||||
)
|
||||
return;
|
||||
// No model connected: keep the draft (don't drop it) and send the user to setup instead.
|
||||
if (needsModel) {
|
||||
props.onConnectModel?.();
|
||||
return;
|
||||
}
|
||||
props.onSend(t, attachments);
|
||||
props.onSend(t, attachments, skill);
|
||||
setText("");
|
||||
setAttachments([]);
|
||||
setPendingSkill(null);
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
if (slashQuery !== null) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSlashIndex((i) => Math.min(i + 1, Math.max(slashMatches.length - 1, 0)));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSlashIndex((i) => Math.max(i - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setText("");
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const chosen = slashMatches[slashIndex];
|
||||
if (chosen) pickSkill(chosen);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
@@ -340,7 +423,9 @@ export function Composer(props: Props) {
|
||||
|
||||
// The send button is accent only when there's something to send — subtle grey otherwise, so the
|
||||
// composer isn't carrying a constant blue dot.
|
||||
const hasContent = text.trim().length > 0 || attachments.length > 0;
|
||||
// A pinned /skill is sendable content on its own (tester catch 2026-07-26: the arrow
|
||||
// stayed grey after picking a skill, reading as "stuck").
|
||||
const hasContent = text.trim().length > 0 || attachments.length > 0 || !!pendingSkill;
|
||||
|
||||
return (
|
||||
<div className="composer-wrap px-6 pb-5 pt-4">
|
||||
@@ -394,6 +479,37 @@ export function Composer(props: Props) {
|
||||
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
{/* "/" force-run popup — in-flow above the textarea; rows are the session's
|
||||
effective menu only (muted/disabled skills never appear). */}
|
||||
{slashQuery !== null && (
|
||||
<div className="px-2 pt-2" data-testid="skill-popup" role="listbox" aria-label="Skills">
|
||||
{slashSkills === null ? (
|
||||
<div className="px-2 py-1.5 text-[12px] text-faint">Loading skills…</div>
|
||||
) : slashMatches.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-[12px] text-faint">No matching skills.</div>
|
||||
) : (
|
||||
slashMatches.map((s, i) => (
|
||||
<button
|
||||
key={s.name}
|
||||
role="option"
|
||||
aria-selected={i === slashIndex}
|
||||
className={
|
||||
"w-full text-left flex items-center gap-2 px-2 py-1.5 rounded-lg " +
|
||||
(i === slashIndex ? "bg-paper" : "hover:bg-paper")
|
||||
}
|
||||
onMouseEnter={() => setSlashIndex(i)}
|
||||
onClick={() => pickSkill(s)}
|
||||
>
|
||||
<span className="text-[13px] font-medium text-accent shrink-0">/{s.name}</span>
|
||||
<span className="text-[12px] text-faint truncate flex-1">{s.description}</span>
|
||||
<span className="text-[10.5px] px-1.5 py-0.5 rounded-full border border-line text-faint shrink-0">
|
||||
{s.scope}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="w-full block px-3.5 pt-3.5 pb-1.5 text-[14.5px]"
|
||||
@@ -469,13 +585,14 @@ export function Composer(props: Props) {
|
||||
|
||||
<span className="ml-auto" />
|
||||
|
||||
{/* token usage (OPE-42) — a quiet meter+count chip; hidden until the server
|
||||
reports usage. Fill = context-window occupancy (bounded), count = session
|
||||
consumption (unbounded, so never a fill). */}
|
||||
{/* token usage (OPE-42) — a quiet chip; hidden until the server reports usage.
|
||||
Shows the context-window fill bar alone (the session total lives in the
|
||||
popover), or the session total when there's no window / the bar is off. */}
|
||||
{!dictation?.recording && props.usage && totalTokens(props.usage) > 0 && (
|
||||
<UsageChip
|
||||
usage={props.usage}
|
||||
contextWindow={props.contextWindow}
|
||||
contextBar={props.contextBar}
|
||||
model={props.model}
|
||||
modelLabels={props.modelLabels}
|
||||
/>
|
||||
@@ -572,11 +689,13 @@ export function Composer(props: Props) {
|
||||
function UsageChip({
|
||||
usage,
|
||||
contextWindow,
|
||||
contextBar,
|
||||
model,
|
||||
modelLabels,
|
||||
}: {
|
||||
usage: SessionUsage;
|
||||
contextWindow?: number;
|
||||
contextBar?: boolean;
|
||||
model: string;
|
||||
modelLabels?: Record<string, string>;
|
||||
}) {
|
||||
@@ -585,6 +704,8 @@ function UsageChip({
|
||||
const pct = contextWindow
|
||||
? Math.min(100, Math.round((usage.context / contextWindow) * 100))
|
||||
: null;
|
||||
// Settings can hide the bar; without a known window there is nothing to fill either.
|
||||
const showBar = pct !== null && contextBar === true;
|
||||
const labelFor = (id: string) =>
|
||||
id === "unknown" ? "Unknown model" : modelLabels?.[id] || shortModel(id);
|
||||
// One field per line, session-summed (owner ask 2026-07-28). Values are cumulative
|
||||
@@ -605,21 +726,25 @@ function UsageChip({
|
||||
aria-expanded={open}
|
||||
aria-label="Token usage"
|
||||
title={
|
||||
pct !== null
|
||||
? `Token usage — ${pct}% of the context window used`
|
||||
: "Token usage this session"
|
||||
showBar
|
||||
? `Context window ${pct}% full · ${formatTokens(total)} tokens this session`
|
||||
: `Token usage this session: ${formatTokens(total)}`
|
||||
}
|
||||
data-testid="usage-chip"
|
||||
>
|
||||
{pct !== null && (
|
||||
<span className="w-7 h-1 rounded-full bg-line overflow-hidden" aria-hidden="true">
|
||||
{/* The bar is the context-window fill; pairing it with the session TOTAL read as
|
||||
"total is N% of the window", which it never was. Bar alone when we have a
|
||||
window, the session total only when we don't (so the chip is never empty). */}
|
||||
{showBar ? (
|
||||
<span className="w-12 h-1.5 rounded-full bg-line overflow-hidden" aria-hidden="true">
|
||||
<span
|
||||
className="block h-full bg-accent transition-all"
|
||||
style={{ width: `${Math.max(pct, 4)}%` }}
|
||||
style={{ width: `${Math.max(pct as number, 4)}%` }}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="tabular-nums">{formatTokens(total)}</span>
|
||||
)}
|
||||
<span className="tabular-nums">{formatTokens(total)}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
|
||||
@@ -9,6 +9,7 @@ export type IconName =
|
||||
| "signOut"
|
||||
| "chat"
|
||||
| "diamond"
|
||||
| "book"
|
||||
| "search"
|
||||
| "folder"
|
||||
| "folderPlus"
|
||||
@@ -66,6 +67,18 @@ export function Icon({
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case "book":
|
||||
// A playbook — Skills are the worker's recipe book (Settings ▸ Skills).
|
||||
// Hardcover with a full spine + two text lines: "written instructions inside".
|
||||
// (Owner-picked from a 15px-preview comparison, 2026-07-27.)
|
||||
return (
|
||||
<svg {...s}>
|
||||
<path d="M6 2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z" />
|
||||
<path d="M8.5 2v20" />
|
||||
<path d="M11.5 8.5h5" />
|
||||
<path d="M11.5 12h3.5" />
|
||||
</svg>
|
||||
);
|
||||
case "sparkle":
|
||||
// Filled 4-point twinkle — crisp at small sizes.
|
||||
return (
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { InboxItem } from "../api";
|
||||
import type { QuestionOption } from "../types";
|
||||
import { humanizeApprovalTitle } from "../humanize";
|
||||
import { PreviewBlock, scopeNote, TitleText } from "./ApprovalCard";
|
||||
import {
|
||||
approvalActionLabels,
|
||||
PreviewBlock,
|
||||
SaveSkillPreview,
|
||||
scopeNote,
|
||||
TitleText,
|
||||
} from "./ApprovalCard";
|
||||
|
||||
// One Inbox item, rendered identically in the Inbox list and inline in its own session view
|
||||
// (answer-in-context). Resolving either place hits the same item id — first responder wins.
|
||||
// Questions (ask_user) mirror Claude Code's AskUserQuestion: optional quick-reply options + an
|
||||
// always-available free-text escape, with optional multi-select.
|
||||
// always-available free-text escape, with optional multi-select. OPE-51 adds rich options
|
||||
// ({label, description, recommended, preview}) and grouped questions (a stepper) — plain-string
|
||||
// options and single questions render exactly as before.
|
||||
|
||||
// Shared styles (mock parity — same language as SourcesDrawer/PersonaView).
|
||||
const SEC = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold";
|
||||
@@ -24,6 +33,260 @@ const OPT_OFF = "border-line bg-paper text-ink hover:border-accent hover:bg-acce
|
||||
const OPT_ON = "border-accent bg-accentSoft text-accent font-medium";
|
||||
const INPUT =
|
||||
"flex-1 min-w-0 rounded-lg bg-paper border border-line px-3 py-2 text-[13px] text-ink placeholder:text-faint outline-none focus:border-lineStrong";
|
||||
// Rich options stack as full-width rows (pills can't hold a description line).
|
||||
const ROW_BASE = "w-full text-left rounded-lg border px-3 py-2 transition-colors";
|
||||
const ROW_OFF = "border-line bg-paper hover:border-accent hover:bg-accentSoft/50";
|
||||
const ROW_ON = "border-accent bg-accentSoft";
|
||||
|
||||
// -- question normalization ---------------------------------------------------
|
||||
|
||||
interface NormOption {
|
||||
label: string;
|
||||
description: string;
|
||||
recommended: boolean;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
const normOption = (o: QuestionOption): NormOption =>
|
||||
typeof o === "string"
|
||||
? { label: o, description: "", recommended: false, preview: "" }
|
||||
: {
|
||||
label: o.label || "",
|
||||
description: o.description || "",
|
||||
recommended: !!o.recommended,
|
||||
preview: o.preview || "",
|
||||
};
|
||||
|
||||
interface QSpec {
|
||||
question: string;
|
||||
header: string;
|
||||
options: NormOption[];
|
||||
allowText: boolean;
|
||||
multi: boolean;
|
||||
}
|
||||
|
||||
// The item's question steps: the grouped `questions` list, or the singular fields as one step.
|
||||
function specsFor(item: InboxItem): QSpec[] {
|
||||
const grouped = item.questions || [];
|
||||
if (grouped.length)
|
||||
return grouped.map((q) => ({
|
||||
question: q.question,
|
||||
header: q.header || "",
|
||||
options: (q.options || []).map(normOption),
|
||||
allowText: q.allow_text !== false,
|
||||
multi: !!q.multi,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
question: item.title,
|
||||
header: item.header || "",
|
||||
options: (item.options || []).map(normOption),
|
||||
allowText: item.allow_text !== false,
|
||||
multi: !!item.multi,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// -- one question (options + free-text escape) --------------------------------
|
||||
|
||||
function QuestionBlock({ spec, onAnswer }: { spec: QSpec; onAnswer: (a: string) => void }) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [text, setText] = useState("");
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||
const { options, multi } = spec;
|
||||
// Any description/preview upgrades pills to stacked rows; any preview adds the side pane.
|
||||
const rich = options.some((o) => o.description || o.preview);
|
||||
const hasPreview = options.some((o) => o.preview);
|
||||
|
||||
const pick = (o: NormOption) => {
|
||||
if (multi)
|
||||
setSelected((s) => (s.includes(o.label) ? s.filter((x) => x !== o.label) : [...s, o.label]));
|
||||
else onAnswer(o.label); // single-select answers immediately (pill behavior, unchanged)
|
||||
};
|
||||
|
||||
// The pane follows hover/focus, falls back to the selected option, then the first preview.
|
||||
const selIdx = options.findIndex((o) => selected.includes(o.label));
|
||||
const previewIdx =
|
||||
hoverIdx ?? (selIdx >= 0 && options[selIdx].preview ? selIdx : options.findIndex((o) => o.preview));
|
||||
const preview = previewIdx >= 0 ? options[previewIdx].preview : "";
|
||||
|
||||
const recommendedTag = (
|
||||
<span className="text-[10px] uppercase tracking-[0.04em] font-semibold text-ok bg-okSoft border border-okLine rounded-full px-1.5 py-px shrink-0">
|
||||
Recommended
|
||||
</span>
|
||||
);
|
||||
|
||||
const optionRows = (
|
||||
<div className={hasPreview ? "flex flex-col gap-2 min-w-0 sm:w-[46%] shrink-0" : "flex flex-col gap-2 mt-2.5"}>
|
||||
{options.map((o, i) => {
|
||||
const on = selected.includes(o.label);
|
||||
return (
|
||||
<button
|
||||
key={o.label + i}
|
||||
className={ROW_BASE + " " + (on ? ROW_ON : ROW_OFF)}
|
||||
onMouseEnter={() => setHoverIdx(i)}
|
||||
onMouseLeave={() => setHoverIdx(null)}
|
||||
onFocus={() => setHoverIdx(i)}
|
||||
onBlur={() => setHoverIdx(null)}
|
||||
onClick={() => pick(o)}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"flex items-center gap-2 text-[13px] " + (on ? "text-accent font-medium" : "text-ink font-medium")
|
||||
}
|
||||
>
|
||||
{multi && on && <span className="text-accent text-[11px] leading-none">✓</span>}
|
||||
<span className="min-w-0 truncate">{o.label}</span>
|
||||
{o.recommended && recommendedTag}
|
||||
</span>
|
||||
{o.description && (
|
||||
<span className="block text-[12px] text-muted mt-0.5 leading-snug">{o.description}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.length > 0 &&
|
||||
(hasPreview ? (
|
||||
// Two-pane: options left, preview right; stacks vertically on narrow widths.
|
||||
<div className="flex flex-col sm:flex-row gap-3 mt-2.5">
|
||||
{optionRows}
|
||||
<pre
|
||||
data-testid="question-preview"
|
||||
className="flex-1 min-w-0 rounded-lg border border-line bg-paper p-3 text-[12px] leading-relaxed font-mono whitespace-pre overflow-auto max-h-72 text-ink"
|
||||
>
|
||||
{preview}
|
||||
</pre>
|
||||
</div>
|
||||
) : rich ? (
|
||||
optionRows
|
||||
) : (
|
||||
// Plain-string options: today's pills, untouched.
|
||||
<div className="flex flex-wrap gap-2 mt-2.5">
|
||||
{options.map((o) => {
|
||||
const on = selected.includes(o.label);
|
||||
return (
|
||||
<button
|
||||
key={o.label}
|
||||
className={OPT_BASE + " " + (on ? OPT_ON : OPT_OFF)}
|
||||
onClick={() => pick(o)}
|
||||
>
|
||||
{multi && on && <span className="text-accent text-[11px] leading-none">✓</span>}
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{multi && options.length > 0 && (
|
||||
<div className="mt-2.5">
|
||||
<button
|
||||
className={BTN_PRIMARY}
|
||||
disabled={!selected.length}
|
||||
onClick={() => onAnswer(selected.join(", "))}
|
||||
>
|
||||
Send{selected.length ? ` (${selected.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(spec.allowText || options.length === 0) && (
|
||||
<div className="flex items-center gap-2 mt-2.5">
|
||||
<input
|
||||
className={INPUT}
|
||||
placeholder={options.length ? "Or type your own answer…" : "Your answer…"}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && text.trim()) onAnswer(text);
|
||||
}}
|
||||
/>
|
||||
<button className={BTN_PRIMARY} disabled={!text.trim()} onClick={() => onAnswer(text)}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// -- the question card (single, or grouped as a stepper) ----------------------
|
||||
|
||||
function QuestionCard({
|
||||
item,
|
||||
onResolve,
|
||||
chip,
|
||||
}: {
|
||||
item: InboxItem;
|
||||
onResolve: (id: string, resolution: string) => void;
|
||||
chip?: ReactNode;
|
||||
}) {
|
||||
const specs = specsFor(item);
|
||||
const grouped = (item.questions?.length ?? 0) > 0;
|
||||
const [step, setStep] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const spec = specs[Math.min(step, specs.length - 1)];
|
||||
const next = step + 1 < specs.length ? specs[step + 1] : null;
|
||||
// The answer map is keyed by header (falling back to the question text) — the same key the
|
||||
// server's answer_result() hands the agent.
|
||||
const keyFor = (s: QSpec) => s.header || s.question;
|
||||
|
||||
const submit = (a: string) => {
|
||||
if (!grouped) {
|
||||
onResolve(item.id, a);
|
||||
return;
|
||||
}
|
||||
const all = { ...answers, [keyFor(spec)]: a };
|
||||
setAnswers(all);
|
||||
if (step + 1 < specs.length) setStep(step + 1);
|
||||
else onResolve(item.id, JSON.stringify(all));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Stepper chips (grouped): "Chart style · 1 of 2 · Distribution ›" — ‹ steps back. */}
|
||||
<div className={SEC + " flex items-center gap-1.5"} data-testid={grouped ? "question-stepper" : undefined}>
|
||||
{grouped && step > 0 && (
|
||||
<button
|
||||
className="text-faint hover:text-ink leading-none text-[13px]"
|
||||
title="Previous question"
|
||||
aria-label="Previous question"
|
||||
onClick={() => setStep(step - 1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
<span className={grouped ? "text-accent" : undefined}>
|
||||
{spec.header || (grouped ? `Question ${step + 1}` : "question")}
|
||||
</span>
|
||||
{grouped && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{step + 1} of {specs.length}
|
||||
</span>
|
||||
{next && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{(next.header || `Question ${step + 2}`) + " ›"}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[15px] font-semibold mt-0.5 leading-snug">{spec.question}</div>
|
||||
{item.body ? (
|
||||
<div className="text-[13px] text-muted mt-1 whitespace-pre-wrap">{item.body}</div>
|
||||
) : null}
|
||||
{chip}
|
||||
{/* key={step} resets selection/text/hover state when the stepper advances */}
|
||||
<QuestionBlock key={step} spec={spec} onAnswer={submit} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function InboxItemCard({
|
||||
item,
|
||||
@@ -36,29 +299,7 @@ export function InboxItemCard({
|
||||
chip?: ReactNode; // optional "go to session" affordance (shown in the Inbox list, not inline)
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const options = item.options || [];
|
||||
const multi = !!item.multi;
|
||||
const allowText = item.allow_text !== false;
|
||||
|
||||
const textRow = (placeholder: string) => (
|
||||
<div className="flex items-center gap-2 mt-2.5">
|
||||
<input
|
||||
className={INPUT}
|
||||
placeholder={placeholder}
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && answer.trim()) onResolve(item.id, answer);
|
||||
}}
|
||||
/>
|
||||
<button className={BTN_PRIMARY} disabled={!answer.trim()} onClick={() => onResolve(item.id, answer)}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const isQuestion = item.kind === "question";
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
@@ -82,27 +323,30 @@ export function InboxItemCard({
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
) : isQuestion ? null : ( // QuestionCard owns its header + title (stepper needs them)
|
||||
<>
|
||||
<div className={SEC}>{item.kind}</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} />
|
||||
) : item.kind === "approval" && item.data?.tool && typeof item.data.arguments?.command === "string" ? (
|
||||
<PreviewBlock text={item.data.arguments.command} />
|
||||
) : item.body ? (
|
||||
) : !isQuestion && item.body ? (
|
||||
<div className="text-[13px] text-muted mt-1 whitespace-pre-wrap">{item.body}</div>
|
||||
) : null}
|
||||
{chip}
|
||||
{!isQuestion && chip}
|
||||
{item.kind === "approval" ? (
|
||||
<div className="flex items-center gap-2 mt-2.5 flex-wrap">
|
||||
<button
|
||||
className={item.data?.tool ? BTN_ACCENT : BTN_PRIMARY}
|
||||
onClick={() => onResolve(item.id, "allow")}
|
||||
>
|
||||
{item.data?.tool ? "Allow once" : "Approve"}
|
||||
{item.data?.tool ? approvalActionLabels(item.data.tool).allow : "Approve"}
|
||||
</button>
|
||||
{/* Task-persistent standing grant (§25) — present only when the approval was
|
||||
raised inside an automation run AND the call can carry a tool+target rule.
|
||||
@@ -120,46 +364,11 @@ export function InboxItemCard({
|
||||
className={item.data?.tool ? BTN_QUIET : BTN_BORDERED}
|
||||
onClick={() => onResolve(item.id, "deny")}
|
||||
>
|
||||
Deny
|
||||
{item.data?.tool ? approvalActionLabels(item.data.tool).deny : "Deny"}
|
||||
</button>
|
||||
</div>
|
||||
) : item.kind === "question" ? (
|
||||
<>
|
||||
{options.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2.5">
|
||||
{options.map((opt) => {
|
||||
const on = selected.includes(opt);
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
className={OPT_BASE + " " + (on ? OPT_ON : OPT_OFF)}
|
||||
onClick={() => {
|
||||
if (multi)
|
||||
setSelected((s) => (on ? s.filter((x) => x !== opt) : [...s, opt]));
|
||||
else onResolve(item.id, opt); // single-select resolves immediately
|
||||
}}
|
||||
>
|
||||
{multi && on && <span className="text-accent text-[11px] leading-none">✓</span>}
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{multi && options.length > 0 && (
|
||||
<div className="mt-2.5">
|
||||
<button
|
||||
className={BTN_PRIMARY}
|
||||
disabled={!selected.length}
|
||||
onClick={() => onResolve(item.id, selected.join(", "))}
|
||||
>
|
||||
Send{selected.length ? ` (${selected.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(allowText || options.length === 0) &&
|
||||
textRow(options.length ? "Or type your own answer…" : "Your answer…")}
|
||||
</>
|
||||
) : isQuestion ? (
|
||||
<QuestionCard item={item} onResolve={onResolve} chip={chip} />
|
||||
) : item.kind === "directory" ? (
|
||||
<div className="flex items-center gap-2 mt-2.5">
|
||||
<button
|
||||
|
||||
@@ -122,7 +122,7 @@ export function Onboarding({ onDone }: { onDone: (next?: "work" | "gallery" | "a
|
||||
<h1 className="text-[19px] font-semibold">Welcome to OpenWorker<span className="beta-tag">BETA</span></h1>
|
||||
<p className="text-[13px] text-muted mt-0.5 mb-4">
|
||||
Pick a model provider to get started — OpenWorker runs on your own key, and your
|
||||
key and your data stay on this Mac.
|
||||
key and your data stay on this computer.
|
||||
</p>
|
||||
|
||||
{!ps.sel ? (
|
||||
@@ -239,7 +239,7 @@ export function Onboarding({ onDone }: { onDone: (next?: "work" | "gallery" | "a
|
||||
Sign in for one-click connections
|
||||
</span>
|
||||
OpenWorker handles the OAuth for 20+ tools — no dev consoles, no pasted keys.
|
||||
Tokens stay on this Mac.
|
||||
Tokens stay on this computer.
|
||||
</span>
|
||||
{signinPhase ? (
|
||||
<span className="inline-flex items-center gap-2 text-[12.5px] text-muted shrink-0">
|
||||
@@ -310,7 +310,7 @@ export function Onboarding({ onDone }: { onDone: (next?: "work" | "gallery" | "a
|
||||
</div>
|
||||
<p className="text-[11px] text-faint mt-3">
|
||||
30+ more tools on the Connectors page — add or remove anytime. Tokens stay on
|
||||
this Mac.
|
||||
this computer.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -159,6 +159,15 @@ export function RightRail({
|
||||
content={content}
|
||||
onReload={reloadSelected}
|
||||
onBack={() => setSelected(null)}
|
||||
onOpenEntry={(path) =>
|
||||
setSelected({
|
||||
path,
|
||||
name: path.split("/").pop() || path,
|
||||
kind: kindFromPath(path),
|
||||
size: 0,
|
||||
modified_at: 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -291,12 +300,15 @@ function ArtifactViewer({
|
||||
content,
|
||||
onReload,
|
||||
onBack,
|
||||
onOpenEntry,
|
||||
}: {
|
||||
sessionId: string;
|
||||
artifact: ArtifactInfo;
|
||||
content: ArtifactContent | null;
|
||||
onReload: () => Promise<void>;
|
||||
onBack: () => void;
|
||||
// Folder listings: open a child entry in the viewer (files and subfolders alike).
|
||||
onOpenEntry?: (path: string) => void;
|
||||
}) {
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const isHtml = content?.kind === "html" && !content.error;
|
||||
@@ -381,6 +393,22 @@ function ArtifactViewer({
|
||||
<CsvTable text={content.content || ""} />
|
||||
) : content.kind === "sheet" ? (
|
||||
<SheetViewer dataUrl={content.data_url || ""} />
|
||||
) : content.kind === "folder" ? (
|
||||
// A linked directory (e.g. a skill package): render the listing, click through.
|
||||
<div className="artifact-folderlist" data-testid="artifact-folder">
|
||||
{(content.entries || []).map((e) => (
|
||||
<button
|
||||
key={e.name}
|
||||
className="artifact-folder-row"
|
||||
onClick={() => onOpenEntry?.(`${artifact.path.replace(/\/+$/, "")}/${e.name}`)}
|
||||
>
|
||||
<Icon name={e.dir ? "folder" : "file"} size={14} />
|
||||
<span className="artifact-folder-name">{e.name}</span>
|
||||
{!e.dir && <span className="artifact-folder-size">{formatBytes(e.size)}</span>}
|
||||
</button>
|
||||
))}
|
||||
{!content.entries?.length && <div className="rail-muted">This folder is empty.</div>}
|
||||
</div>
|
||||
) : content.kind === "office" ? (
|
||||
<div className="artifact-open-prompt">
|
||||
<Icon name="panelOpen" size={28} />
|
||||
|
||||
@@ -2,11 +2,14 @@ import { useEffect, useState } from "react";
|
||||
import {
|
||||
getSettings,
|
||||
getTrustedWorkspaces,
|
||||
setCompactionSettings,
|
||||
setContextBar,
|
||||
setOnboarded,
|
||||
setPdfSettings,
|
||||
setScratchBase,
|
||||
setSessionsPeek,
|
||||
setWorkspaceTrusted,
|
||||
type CompactionSettings,
|
||||
type ModelSettings,
|
||||
type PdfSettings,
|
||||
type WorkspaceCommandTrust,
|
||||
@@ -39,6 +42,7 @@ import { ModelsTab } from "./ManageTabs";
|
||||
import { MemorySection } from "./MemorySection";
|
||||
import { GalleryModal } from "./GalleryModal";
|
||||
import { PersonasTab } from "./PersonasTab";
|
||||
import { SkillsTab } from "./SkillsTab";
|
||||
import { showPersonas } from "../flags";
|
||||
|
||||
// Settings, restructured (Option 2) into a full-page surface that mirrors IntegrationsView's shell:
|
||||
@@ -48,7 +52,7 @@ import { showPersonas } from "../flags";
|
||||
// Models + Personas host the existing tab components inside the page shell (field re-skin to follow).
|
||||
// "appearance" is the General tab's stable key — callers deep-link with it, so the
|
||||
// rename (UX-021) changed only the label. "files" folded into General as a card.
|
||||
type SetTab = "appearance" | "models" | "voice" | "memory" | "personas";
|
||||
type SetTab = "appearance" | "models" | "skills" | "voice" | "memory" | "personas";
|
||||
|
||||
const CARD = "rounded-xl2 border border-line bg-panel";
|
||||
const FIELD_LABEL = "text-[12.5px] font-medium text-ink";
|
||||
@@ -59,9 +63,14 @@ const BTN_ACCENT = "text-[12.5px] px-3 py-2 rounded-lg bg-accent text-white shri
|
||||
const BTN_BORDERED =
|
||||
"text-[12.5px] px-3 py-2 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
|
||||
|
||||
const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" | "archive" | "sparkle" }[] = [
|
||||
const SET_TABS: {
|
||||
key: SetTab;
|
||||
label: string;
|
||||
icon: "sliders" | "code" | "mic" | "archive" | "sparkle" | "book";
|
||||
}[] = [
|
||||
{ key: "appearance", label: "General", icon: "sliders" },
|
||||
{ key: "models", label: "Models", icon: "code" },
|
||||
{ key: "skills", label: "Skills", icon: "book" },
|
||||
{ key: "voice", label: "Voice input", icon: "mic" },
|
||||
{ key: "memory", label: "Memory", icon: "archive" },
|
||||
{ key: "personas", label: "Personas", icon: "sparkle" },
|
||||
@@ -70,9 +79,13 @@ const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" |
|
||||
export function SettingsView({
|
||||
initialTab,
|
||||
onOpenPersona,
|
||||
onCreateSkill,
|
||||
}: {
|
||||
initialTab?: SetTab;
|
||||
onOpenPersona?: (id: string) => void;
|
||||
// Skills doorway (SKILLS-SPEC §5.2): start a new conversation with the description
|
||||
// prefilled — the worker builds the skill and proposes it via save_skill.
|
||||
onCreateSkill?: (description: string) => void;
|
||||
}) {
|
||||
// Personas is flag-gated (hidden for launch) — filter the tab AND coerce a stale
|
||||
// deep-link to it (openSettings("personas") callers) so the page never opens on a
|
||||
@@ -120,8 +133,11 @@ export function SettingsView({
|
||||
not under General. */}
|
||||
<div className="mt-6">
|
||||
<TokenSavingsCard />
|
||||
<CompactionCard />
|
||||
</div>
|
||||
</section>
|
||||
) : tab === "skills" ? (
|
||||
<SkillsTab onCreateSkill={onCreateSkill} />
|
||||
) : tab === "voice" ? (
|
||||
<VoiceInputSection />
|
||||
) : tab === "memory" ? (
|
||||
@@ -429,6 +445,8 @@ function AppearanceSection() {
|
||||
|
||||
<SidebarCard />
|
||||
|
||||
<ContextBarCard />
|
||||
|
||||
<FilesCard />
|
||||
|
||||
<TrustedWorkspacesCard />
|
||||
@@ -588,9 +606,9 @@ function UpdateInline() {
|
||||
// -- Sidebar density -------------------------------------------------------------
|
||||
// -- Token savings (PDF attachments; owner ask, 2026-07-17) ---------------------
|
||||
// Attachments replay with EVERY turn, so a big PDF quietly multiplies token spend.
|
||||
// Auto-compaction of long histories is a planned follow-up (punchlist §7) — until
|
||||
// then this card is the user's dial: attach thresholds + the fallback for models
|
||||
// without native PDF support.
|
||||
// This card is the attachment dial: attach thresholds + the fallback for models
|
||||
// without native PDF support. (Long-history spend is handled by auto-compaction —
|
||||
// the CompactionCard below, OPE-27.)
|
||||
function TokenSavingsCard() {
|
||||
const [pdf, setPdf] = useState<PdfSettings | null>(null);
|
||||
|
||||
@@ -676,6 +694,163 @@ function TokenSavingsCard() {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Context compaction (OPE-27) ------------------------------------------------
|
||||
// Long sessions are summarized automatically when they approach the model's context
|
||||
// limit, so work continues instead of hitting a raw provider error. Two spec'd
|
||||
// overrides (trigger % + token cap) and the summarizer-model pin — nothing more.
|
||||
function CompactionCard() {
|
||||
const [cfg, setCfg] = useState<CompactionSettings | null>(null);
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [labels, setLabels] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => {
|
||||
setCfg({
|
||||
compaction_threshold_pct: s.compaction_threshold_pct ?? 0.8,
|
||||
compaction_cap_tokens: s.compaction_cap_tokens ?? 250_000,
|
||||
compaction_model: s.compaction_model ?? "",
|
||||
});
|
||||
setModels(s.models || []);
|
||||
setLabels(s.model_labels || {});
|
||||
})
|
||||
.catch(() =>
|
||||
setCfg({
|
||||
compaction_threshold_pct: 0.8,
|
||||
compaction_cap_tokens: 250_000,
|
||||
compaction_model: "",
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const save = async (patch: Partial<CompactionSettings>) => {
|
||||
setCfg((p) => (p ? { ...p, ...patch } : p));
|
||||
await setCompactionSettings(patch);
|
||||
};
|
||||
|
||||
if (!cfg) return null;
|
||||
const modelLabel = (id: string) => labels[id]?.split(" · ")[0] || id;
|
||||
return (
|
||||
<div className={CARD + " p-4 mb-4"} data-testid="compaction-card">
|
||||
<div className={FIELD_LABEL}>Context compaction</div>
|
||||
<div className={FIELD_HELP}>
|
||||
Long sessions are compacted automatically: older turns are summarized so the
|
||||
coworker keeps working instead of running out of context. Your visible transcript
|
||||
is never changed — a small marker shows where compaction happened.
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-5 flex-wrap">
|
||||
<label className="flex items-center gap-2.5">
|
||||
<span className="text-[13px] text-ink">Compact at</span>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
max={95}
|
||||
value={Math.round(cfg.compaction_threshold_pct * 100)}
|
||||
data-testid="compaction-threshold"
|
||||
className="w-16 px-2 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent"
|
||||
onChange={(e) =>
|
||||
save({
|
||||
compaction_threshold_pct:
|
||||
Math.max(10, Math.min(Number(e.target.value) || 80, 95)) / 100,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-[12.5px] text-muted">% of the context window</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5">
|
||||
<span className="text-[13px] text-ink">or at</span>
|
||||
<input
|
||||
type="number"
|
||||
min={10_000}
|
||||
max={2_000_000}
|
||||
step={10_000}
|
||||
value={cfg.compaction_cap_tokens}
|
||||
data-testid="compaction-cap"
|
||||
className="w-28 px-2 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent"
|
||||
onChange={(e) =>
|
||||
save({
|
||||
compaction_cap_tokens: Math.max(
|
||||
10_000,
|
||||
Math.min(Number(e.target.value) || 250_000, 2_000_000),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-[12.5px] text-muted">tokens, whichever is smaller</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className={FIELD_HELP}>
|
||||
The cap makes very-large-context models compact early — quality and speed degrade
|
||||
well before their nominal limit.
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2.5">
|
||||
<span className="text-[13px] text-ink">Summarizer model</span>
|
||||
<select
|
||||
value={cfg.compaction_model}
|
||||
data-testid="compaction-model"
|
||||
className="px-2 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent"
|
||||
onChange={(e) => save({ compaction_model: e.target.value })}
|
||||
>
|
||||
<option value="">Session’s own model (default)</option>
|
||||
{models.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{modelLabel(m)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={FIELD_HELP}>
|
||||
The summary is written by this model. The default follows whatever model the
|
||||
session is using.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Composer: context-window bar (owner ask 2026-07-30) ------------------------
|
||||
// The chip's bar is context-window occupancy; the session total (unbounded) lives in
|
||||
// the popover. Some people would rather not watch a meter at all, hence the toggle.
|
||||
function ContextBarCard() {
|
||||
const [shown, setShown] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setShown(s.context_bar === true))
|
||||
.catch(() => setShown(false));
|
||||
}, []);
|
||||
|
||||
const save = async (next: boolean) => {
|
||||
setShown(next);
|
||||
await setContextBar(next);
|
||||
};
|
||||
|
||||
if (shown === null) return null;
|
||||
return (
|
||||
<div className={CARD + " p-4 mb-4"} data-testid="context-bar-card">
|
||||
<div className={FIELD_LABEL}>Composer</div>
|
||||
<label className="flex items-start gap-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
data-testid="context-bar-toggle"
|
||||
checked={shown}
|
||||
onChange={(e) => save(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-[13px] text-ink">Show the context window bar</span>
|
||||
<span className="block text-[12px] text-muted">
|
||||
A small meter showing how full the model’s context window is. Turn it off
|
||||
to show this session’s token total instead; either way the full breakdown
|
||||
is one click away.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarCard() {
|
||||
const [peek, setPeek] = useState<number | null>(null);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,28 @@ import { Markdown } from "./Markdown";
|
||||
import { ConnectorMessageCard } from "./ConnectorMessageCard";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
// Long user pastes swallow the transcript (owner ask 2026-07-30): clamp past a generous
|
||||
// threshold with a more…/less… toggle. Normal typed messages never see the control; the
|
||||
// full text still drives copy (BubbleMeta) and is what the model received.
|
||||
const USER_CLAMP_CHARS = 1200;
|
||||
|
||||
function ClampedUserText({ text }: { text: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (text.length <= USER_CLAMP_CHARS) return <>{text}</>;
|
||||
return (
|
||||
<>
|
||||
{open ? text : text.slice(0, USER_CLAMP_CHARS).trimEnd() + "…"}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="block ml-auto mt-1.5 text-[12.5px] font-medium opacity-75 hover:opacity-100"
|
||||
>
|
||||
{open ? "less…" : "more…"}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Hover affordances for a message bubble (FB-005): copy the raw text + the message's time.
|
||||
// Lives in a ZERO-HEIGHT strip under the bubble (absolute, inside the transcript's 20px gap)
|
||||
// so revealing it on group-hover never shifts the layout. `ts` is unix seconds — canonical
|
||||
@@ -165,7 +187,15 @@ function StepRow({ tool, approval }: { tool: ToolItem; approval?: ApprovalItem }
|
||||
<span className={"w-3.5 text-center text-[10px] shrink-0 " + (failed ? "text-danger" : running ? "text-accent" : "text-ok")}>
|
||||
{running ? <span className="spinner" data-testid="step-running" /> : "●"}
|
||||
</span>
|
||||
<LineText line={humanizeTool(tool.name, tool.args)} />
|
||||
<LineText
|
||||
line={
|
||||
// A refused load must not read as a success — "Used skill:" is the trust line
|
||||
// (SKILLS-SPEC §4.1 #4), so a blocked attempt gets honest wording instead.
|
||||
tool.name === "load_skill" && tool.preview?.includes('"error"')
|
||||
? { pre: "Tried skill: ", obj: String(tool.args?.name ?? ""), post: " — not available" }
|
||||
: humanizeTool(tool.name, tool.args)
|
||||
}
|
||||
/>
|
||||
{approval && approvalChip(approval.resolved)}
|
||||
{!!tool.standingRule && (
|
||||
<span
|
||||
@@ -401,7 +431,7 @@ export function Transcript({ items, running, streamingText, onRetry, onUndoMemor
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{item.text}
|
||||
<ClampedUserText text={item.text} />
|
||||
</div>
|
||||
<BubbleMeta text={item.text} ts={item.ts} align="right" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// SKILLS-SPEC §4.6 GUI — the transcript trust line: a load_skill tool call always renders
|
||||
// as a human-readable "Used skill: X" step, whether model-invoked or forced via /skill.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { humanizeTool } from "./humanize";
|
||||
|
||||
describe("humanizeTool(load_skill)", () => {
|
||||
it("renders the Used-skill line with the skill name", () => {
|
||||
const line = humanizeTool("load_skill", { name: "incident-summary" });
|
||||
expect(line.pre).toBe("Used skill: ");
|
||||
expect(line.obj).toBe("incident-summary");
|
||||
});
|
||||
|
||||
it("stays safe on null/missing args", () => {
|
||||
expect(humanizeTool("load_skill", null).obj).toBe("");
|
||||
expect(humanizeTool("load_skill", {}).obj).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,10 @@ export function humanizeTool(name: string, args: any): HumanLine {
|
||||
}
|
||||
case "explore":
|
||||
return { pre: "Sent a sub-agent to explore — ", obj: `“${trunc(String(a.task ?? a.prompt ?? ""), 60)}”` };
|
||||
case "load_skill":
|
||||
// SKILLS-SPEC §4.1 #4 — the trust line: the transcript always shows the moment a
|
||||
// skill's instructions were picked up, model-invoked or forced via /skill.
|
||||
return { pre: "Used skill: ", obj: String(a.name ?? "") };
|
||||
case "ask_user":
|
||||
return { pre: "Asked you a question" };
|
||||
case "propose_plan":
|
||||
@@ -131,6 +135,11 @@ export function humanizeApprovalTitle(name: string, args: any): HumanLine {
|
||||
return a.title
|
||||
? { pre: "Create the automation ", obj: `“${trunc(String(a.title), 60)}”` }
|
||||
: { pre: "Create an automation" };
|
||||
case "save_skill":
|
||||
// SKILLS-SPEC §5.2/§7: "Add", never "install"; destination is "your skills".
|
||||
return a.name
|
||||
? { pre: "Add skill ", obj: String(a.name), post: " to your skills" }
|
||||
: { pre: "Add a skill to your skills" };
|
||||
default:
|
||||
return { pre: `Use ${name}` };
|
||||
}
|
||||
|
||||
@@ -83,6 +83,20 @@ describe("itemsFromMessages model switch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("itemsFromMessages compaction", () => {
|
||||
it("replays the persisted compacted marker as an info notice (the divider)", () => {
|
||||
const items = itemsFromMessages([
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "notice", kind: "compacted", text: "Context compacted — earlier turns were summarized" },
|
||||
] as any);
|
||||
expect(items[1]).toEqual({
|
||||
kind: "notice",
|
||||
tone: "info",
|
||||
text: "Context compacted — earlier turns were summarized",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("itemsFromMessages reasoning", () => {
|
||||
it("attaches the reasoning sidecar to assistant items; thinking-only messages still render", () => {
|
||||
const items = itemsFromMessages([
|
||||
|
||||
@@ -33,6 +33,9 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
|
||||
continue;
|
||||
}
|
||||
const user = userItemFromContent(m.content);
|
||||
// Force-run (`/skill …`): `_display` holds the user's literal line; `content` carries
|
||||
// the model-facing framing. Render what the user typed — one truthful bubble.
|
||||
if (typeof m._display === "string" && m._display) user.text = m._display;
|
||||
// `ts` (unix seconds) is the server's canonical-message stamp; older sessions have none.
|
||||
if (typeof m.ts === "number") user.ts = m.ts;
|
||||
if (user.text || user.attachments?.length) items.push(user);
|
||||
@@ -72,7 +75,10 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
|
||||
? { kind: "notice", tone: "warn", text: "Interrupted." }
|
||||
: m.kind === "model_switch"
|
||||
? { kind: "notice", tone: "info", text: m.text || "Model switched" }
|
||||
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
|
||||
: m.kind === "compacted"
|
||||
? // The subtle "compacted here" divider (OPE-27) — the transcript itself is intact.
|
||||
{ kind: "notice", tone: "info", text: m.text || "Context compacted" }
|
||||
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
|
||||
);
|
||||
}
|
||||
// system messages are omitted; tool-result messages are folded into the tool row above
|
||||
|
||||
@@ -499,7 +499,7 @@ export function ProviderForm({
|
||||
)}
|
||||
{info && !info.needs_key && (
|
||||
<p className="text-[11.5px] text-faint mt-2">
|
||||
No API key needed — Ollama runs models on this Mac.{" "}
|
||||
No API key needed — Ollama runs models on this computer.{" "}
|
||||
<button
|
||||
className="text-muted underline decoration-line underline-offset-2 hover:text-ink"
|
||||
onClick={() => openExternal("https://ollama.com/download")}
|
||||
|
||||
@@ -397,6 +397,9 @@ body {
|
||||
margin-top: 11px; font-family: var(--mono); font-size: 12px; color: var(--muted);
|
||||
background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 8px 11px;
|
||||
overflow-wrap: anywhere; white-space: pre-wrap; line-height: 1.55;
|
||||
/* Long previews (a 170-line skill) must scroll INSIDE the card — expanded content
|
||||
otherwise outgrows the viewport with no way to read or reach "show less". */
|
||||
max-height: 42vh; overflow-y: auto;
|
||||
}
|
||||
.approval-prev-more {
|
||||
display: block; margin-top: 4px; font: inherit; font-family: -apple-system, sans-serif;
|
||||
@@ -1603,3 +1606,14 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
|
||||
fussy on a pill); small + high keeps the wordmark the star. */
|
||||
vertical-align: 3px;
|
||||
}
|
||||
|
||||
/* Folder artifact listing (a linked package dir renders as rows, not "not found"). */
|
||||
.artifact-folderlist { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; }
|
||||
.artifact-folder-row {
|
||||
display: flex; align-items: center; gap: 9px; width: 100%; text-align: left;
|
||||
padding: 7px 10px; border: 0; border-radius: 8px; background: none; cursor: pointer;
|
||||
color: var(--ink); font-size: 12.5px;
|
||||
}
|
||||
.artifact-folder-row:hover { background: var(--paper); }
|
||||
.artifact-folder-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.artifact-folder-size { font-size: 11px; color: var(--faint); }
|
||||
|
||||
@@ -19,6 +19,8 @@ export type EventType =
|
||||
| "interrupted"
|
||||
| "model_changed"
|
||||
| "memory_saved"
|
||||
| "compacting"
|
||||
| "compacted"
|
||||
| "turn_done";
|
||||
|
||||
export interface WsEvent {
|
||||
@@ -132,9 +134,11 @@ export type Item =
|
||||
// A live ask_user prompt (attended sessions answer inline; unattended ones route to the Inbox).
|
||||
kind: "question";
|
||||
question: string;
|
||||
options?: string[];
|
||||
options?: QuestionOption[];
|
||||
allow_text?: boolean;
|
||||
multi?: boolean;
|
||||
header?: string;
|
||||
questions?: GroupedQuestion[];
|
||||
resolved?: string;
|
||||
}
|
||||
| { kind: "notice"; tone: "info" | "warn"; text: string; retriable?: boolean }
|
||||
@@ -144,3 +148,21 @@ export type Item =
|
||||
// EDITED rather than a new one added (the update-don't-duplicate rule sends many
|
||||
// saves that way) — Undo restores that text instead of deleting the memory.
|
||||
| { kind: "memory"; id: number; text: string; previous?: string; undone?: boolean };
|
||||
|
||||
// -- ask_user question metadata (OPE-51) --------------------------------------
|
||||
// An option is a plain string (renders as today's pill) or a rich object: `label` is the answer
|
||||
// value, `description` renders under it, `recommended` adds the green tag, `preview` is monospace
|
||||
// text shown in the side pane (≥1 preview switches the card to the two-pane layout).
|
||||
export type QuestionOption =
|
||||
| string
|
||||
| { label: string; description?: string; recommended?: boolean; preview?: string };
|
||||
|
||||
// One step of a grouped ask_user call (up to 4, rendered as a stepper). The answer map is keyed
|
||||
// by `header` (falling back to `question`).
|
||||
export interface GroupedQuestion {
|
||||
question: string;
|
||||
header?: string;
|
||||
options?: QuestionOption[];
|
||||
allow_text?: boolean;
|
||||
multi?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""list_artifacts must never descend into OS application-data directories.
|
||||
|
||||
On macOS 14+, merely traversing ~/Library/Application Support (other apps' containers)
|
||||
trips the App Data TCC protection and the user gets an alarming "OpenWorker would like to
|
||||
access data from other apps" prompt. The artifacts panel refreshes after every turn, so a
|
||||
home-directory workspace produced that prompt unprompted. Pruning must happen DURING the
|
||||
walk (rglob descends first and filters after, which is what caused the bug).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from coworker.server.manager import SessionManager
|
||||
from coworker.tools.search import OS_DATA_DIRS
|
||||
|
||||
|
||||
def _ws(tmp_path):
|
||||
ws = tmp_path / "home"
|
||||
(ws / "Library" / "Application Support" / "SomeOtherApp").mkdir(parents=True)
|
||||
(ws / "Library" / "Application Support" / "SomeOtherApp" / "secrets.json").write_text("{}")
|
||||
(ws / "Library" / "notes.md").write_text("# private")
|
||||
(ws / "node_modules" / "pkg").mkdir(parents=True)
|
||||
(ws / "node_modules" / "pkg" / "readme.md").write_text("# dep")
|
||||
(ws / "report.md").write_text("# real artifact")
|
||||
return ws
|
||||
|
||||
|
||||
def test_os_data_dirs_are_not_traversed(tmp_path, monkeypatch):
|
||||
ws = _ws(tmp_path)
|
||||
walked: list[str] = []
|
||||
real_walk = os.walk
|
||||
|
||||
def spy(top, *a, **k):
|
||||
for dirpath, dirs, files in real_walk(top, *a, **k):
|
||||
walked.append(dirpath)
|
||||
yield dirpath, dirs, files
|
||||
|
||||
monkeypatch.setattr("coworker.server.manager.os.walk", spy)
|
||||
m = SessionManager(data_dir=tmp_path / "data", workspace=str(ws))
|
||||
names = [a["name"] for a in m.list_artifacts("s1")]
|
||||
|
||||
assert "report.md" in names
|
||||
# The private file is skipped AND its directory was never entered (the TCC trigger).
|
||||
assert "notes.md" not in names
|
||||
assert "secrets.json" not in names
|
||||
assert not any("Library" in p for p in walked), f"descended into Library: {walked}"
|
||||
assert not any("node_modules" in p for p in walked)
|
||||
|
||||
|
||||
def test_os_data_dirs_cover_mac_and_windows():
|
||||
assert {"Library", "AppData", "Application Data"} <= OS_DATA_DIRS
|
||||
@@ -0,0 +1,218 @@
|
||||
"""OPE-51 — ask_user upgrades: rich options ({label, description, recommended, preview}),
|
||||
grouped questions (one call, a stepper, one round-trip), and the {answer}/{answers} result
|
||||
shapes. Back-compat is load-bearing: plain-string options and old persisted items must be
|
||||
untouched by all of it."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from coworker.inbox import InboxItem, InboxStore
|
||||
from coworker.interactions import buttons_for, decode
|
||||
from coworker.server.manager import SessionManager
|
||||
from coworker.tools.ask import (
|
||||
MAX_GROUPED_QUESTIONS,
|
||||
answer_result,
|
||||
ask_user_tool,
|
||||
normalize_option,
|
||||
normalize_questions,
|
||||
option_label,
|
||||
question_item_fields,
|
||||
)
|
||||
|
||||
from test_durable_resume import ScriptedProvider, _run_until_pending, _text, _tool
|
||||
|
||||
|
||||
# -- schema -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_schema_advertises_rich_options_and_grouped_questions():
|
||||
fn = ask_user_tool().__coworker_schema__["function"]
|
||||
assert fn["name"] == "ask_user"
|
||||
props = fn["parameters"]["properties"]
|
||||
# options: string-or-object union, object requires `label`
|
||||
variants = props["options"]["items"]["anyOf"]
|
||||
assert {"type": "string"} in variants
|
||||
obj = next(v for v in variants if v.get("type") == "object")
|
||||
assert obj["required"] == ["label"]
|
||||
assert set(obj["properties"]) == {"label", "description", "recommended", "preview"}
|
||||
# grouped: capped, each entry requires `question`
|
||||
grouped = props["questions"]
|
||||
assert grouped["maxItems"] == MAX_GROUPED_QUESTIONS
|
||||
assert grouped["items"]["required"] == ["question"]
|
||||
|
||||
|
||||
# -- normalization helpers ----------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_option_and_label():
|
||||
assert normalize_option("Bar") == {
|
||||
"label": "Bar",
|
||||
"description": "",
|
||||
"recommended": False,
|
||||
"preview": "",
|
||||
}
|
||||
rich = normalize_option({"label": "Line", "recommended": True, "preview": "p"})
|
||||
assert rich["recommended"] is True and rich["preview"] == "p"
|
||||
assert option_label("Bar") == "Bar" and option_label({"label": "Line"}) == "Line"
|
||||
|
||||
|
||||
def test_normalize_questions_caps_and_drops_blanks():
|
||||
entries = [{"question": f"Q{i}?"} for i in range(MAX_GROUPED_QUESTIONS + 2)]
|
||||
assert len(normalize_questions(entries)) == MAX_GROUPED_QUESTIONS
|
||||
assert normalize_questions([{"question": " "}, "junk", {"header": "h"}]) == []
|
||||
|
||||
|
||||
def test_question_item_fields_plain_strings_pass_through():
|
||||
fields = question_item_fields({"question": "Env?", "options": ["staging", "prod"]})
|
||||
assert fields["title"] == "Env?"
|
||||
assert fields["options"] == ["staging", "prod"] # simple asks stay today's pills
|
||||
assert fields["questions"] == []
|
||||
|
||||
|
||||
def test_question_item_fields_rich_options_canonicalized():
|
||||
fields = question_item_fields(
|
||||
{"question": "Env?", "options": [{"label": "staging", "recommended": True}]}
|
||||
)
|
||||
assert fields["options"] == [
|
||||
{"label": "staging", "description": "", "recommended": True, "preview": ""}
|
||||
]
|
||||
|
||||
|
||||
def test_question_item_fields_grouped_surfaces_first_question():
|
||||
fields = question_item_fields(
|
||||
{
|
||||
"questions": [
|
||||
{"question": "Chart style?", "header": "Chart", "options": ["Bar"]},
|
||||
{"question": "Colors?", "multi": True},
|
||||
]
|
||||
}
|
||||
)
|
||||
assert fields["title"] == "Chart style?" and fields["header"] == "Chart"
|
||||
assert fields["options"][0]["label"] == "Bar"
|
||||
assert len(fields["questions"]) == 2 and fields["questions"][1]["multi"] is True
|
||||
|
||||
|
||||
def test_question_item_fields_nothing_asked():
|
||||
assert question_item_fields({}) is None
|
||||
assert question_item_fields({"question": " "}) is None
|
||||
assert question_item_fields({"questions": [{"question": ""}]}) is None
|
||||
|
||||
|
||||
# -- result shaping -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_answer_result_shapes():
|
||||
assert answer_result([], "staging") == {"answer": "staging"}
|
||||
assert answer_result([], None) == {"answer": ""}
|
||||
grouped = [{"question": "Chart style?", "header": "Chart"}, {"question": "Colors?"}]
|
||||
res = answer_result(grouped, json.dumps({"Chart": "Bar", "Colors?": "Blue"}))
|
||||
assert res == {"answers": {"Chart": "Bar", "Colors?": "Blue"}}
|
||||
# a text-only surface answered with a bare string → attributed to the first question
|
||||
assert answer_result(grouped, "Bar") == {"answers": {"Chart": "Bar"}}
|
||||
assert answer_result(grouped, "") == {"answer": ""} # engine reads this as denied
|
||||
|
||||
|
||||
# -- inbox persistence + back-compat ------------------------------------------
|
||||
|
||||
|
||||
def test_inbox_round_trips_grouped_questions(tmp_path):
|
||||
store = InboxStore(tmp_path / "inbox.json")
|
||||
fields = question_item_fields(
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Format?",
|
||||
"header": "Format",
|
||||
"options": [{"label": "Table", "preview": "| a | b |"}],
|
||||
},
|
||||
{"question": "Where to?"},
|
||||
]
|
||||
}
|
||||
)
|
||||
item = store.add_question("s1", **fields)
|
||||
reloaded = InboxStore(tmp_path / "inbox.json").get(item.id)
|
||||
assert reloaded.header == "Format"
|
||||
assert reloaded.questions == item.questions
|
||||
assert reloaded.options[0]["preview"] == "| a | b |"
|
||||
|
||||
|
||||
def test_old_persisted_items_still_load():
|
||||
# Items written before OPE-51 carry no header/questions keys and string options.
|
||||
old = InboxItem(
|
||||
id="x", session_id="s", kind="question", title="Env?", options=["staging"]
|
||||
)
|
||||
assert old.header == "" and old.questions == []
|
||||
|
||||
|
||||
# -- channel buttons ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_buttons_use_rich_option_labels(tmp_path):
|
||||
store = InboxStore(tmp_path / "inbox.json")
|
||||
item = store.add_question(
|
||||
"s1",
|
||||
"Env?",
|
||||
options=["staging", {"label": "prod", "description": "the real one"}],
|
||||
)
|
||||
btns = buttons_for(item)
|
||||
assert [b.label for b in btns] == ["staging", "prod"]
|
||||
assert decode(btns[1].value) == (item.id, "prod") # resolution IS the label
|
||||
|
||||
|
||||
def test_grouped_questions_get_no_buttons(tmp_path):
|
||||
store = InboxStore(tmp_path / "inbox.json")
|
||||
fields = question_item_fields(
|
||||
{"questions": [{"question": "A?", "options": ["x"]}, {"question": "B?"}]}
|
||||
)
|
||||
item = store.add_question("s1", **fields)
|
||||
assert buttons_for(item) == [] # one button row can't answer 2+ questions
|
||||
|
||||
|
||||
# -- full stack: grouped call → Inbox item → JSON resolution → {answers} ------
|
||||
|
||||
|
||||
def test_grouped_ask_round_trip_through_manager(tmp_path):
|
||||
mgr = SessionManager(
|
||||
workspace=tmp_path,
|
||||
provider=ScriptedProvider(
|
||||
[
|
||||
_tool(
|
||||
"ask_user",
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Chart style?",
|
||||
"header": "Chart",
|
||||
"options": [
|
||||
{"label": "Bar", "recommended": True},
|
||||
"Line",
|
||||
],
|
||||
},
|
||||
{"question": "Which distribution?", "header": "Distribution"},
|
||||
]
|
||||
},
|
||||
"call_g",
|
||||
),
|
||||
_text("Bar it is, stacked."),
|
||||
]
|
||||
),
|
||||
)
|
||||
sid = "grouped-q"
|
||||
|
||||
async def scenario():
|
||||
engine = mgr.get_engine(sid, agent="cowork", workspace=str(tmp_path))
|
||||
item = await _run_until_pending(mgr, sid, engine)
|
||||
assert item.kind == "question" and item.tool_call_id == "call_g"
|
||||
assert item.title == "Chart style?" and len(item.questions) == 2
|
||||
await mgr.resolve_inbox(
|
||||
item.id, json.dumps({"Chart": "Bar", "Distribution": "Stacked"})
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
# The tool result the model saw carries the parsed answers map.
|
||||
rec = mgr.session_store.load(sid)
|
||||
tool_msgs = [m for m in rec.messages if m.get("role") == "tool"]
|
||||
assert tool_msgs, "no tool result was recorded"
|
||||
payload = json.loads(tool_msgs[-1]["content"])
|
||||
assert payload == {"answers": {"Chart": "Bar", "Distribution": "Stacked"}}
|
||||
assert mgr.inbox.pending(sid) == []
|
||||
@@ -0,0 +1,346 @@
|
||||
"""OPE-27 — auto-compaction pure functions: trigger math, boundary picking, mechanical
|
||||
extraction, summarizer seam, trim fallback, outbound view. No engine involved."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from coworker.compaction import (
|
||||
CompactionState,
|
||||
DEFAULT_CAP_TOKENS,
|
||||
DEFAULT_CONTEXT_WINDOW,
|
||||
apply_to_outbound,
|
||||
build_state,
|
||||
compacted_block,
|
||||
estimate_tokens,
|
||||
extract_user_messages,
|
||||
extract_working_state,
|
||||
is_context_overflow,
|
||||
pick_boundary,
|
||||
should_compact,
|
||||
summarize_span,
|
||||
summarizer_messages,
|
||||
trigger_tokens,
|
||||
trim_state,
|
||||
)
|
||||
|
||||
|
||||
# -- message builders ---------------------------------------------------------
|
||||
|
||||
|
||||
def user(text):
|
||||
return {"role": "user", "content": text, "ts": 1.0}
|
||||
|
||||
|
||||
_call_seq = 0
|
||||
|
||||
|
||||
def assistant(text="", tool_calls=None):
|
||||
global _call_seq
|
||||
msg = {"role": "assistant", "content": text, "ts": 1.0}
|
||||
if tool_calls:
|
||||
calls = []
|
||||
for name, args in tool_calls:
|
||||
calls.append(
|
||||
{
|
||||
"id": f"c{_call_seq}",
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": json.dumps(args)},
|
||||
}
|
||||
)
|
||||
_call_seq += 1
|
||||
msg["tool_calls"] = calls
|
||||
return msg
|
||||
|
||||
|
||||
def tool(call_id, content):
|
||||
return {
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"content": content if isinstance(content, str) else json.dumps(content),
|
||||
"ts": 1.0,
|
||||
}
|
||||
|
||||
|
||||
def tool_turn(name, args, result):
|
||||
"""[assistant tool-call, matching tool result] with a properly paired call id."""
|
||||
a = assistant(tool_calls=[(name, args)])
|
||||
return [a, tool(a["tool_calls"][0]["id"], result)]
|
||||
|
||||
|
||||
def convo(turns=6, bulk=2000):
|
||||
"""system + N user/assistant turns with bulky assistant text."""
|
||||
msgs = [{"role": "system", "content": "You are a coworker."}]
|
||||
for i in range(turns):
|
||||
msgs.append(user(f"request {i}"))
|
||||
msgs.append(assistant(f"answer {i} " + "x" * bulk))
|
||||
return msgs
|
||||
|
||||
|
||||
class FakeSummarizer:
|
||||
def __init__(self, text="## Summary\nall good", fail_times=0):
|
||||
self.text = text
|
||||
self.fail_times = fail_times
|
||||
self.calls = []
|
||||
|
||||
def complete(self, *, model, messages, tools=None, **settings):
|
||||
self.calls.append({"model": model, "messages": messages, "tools": tools, **settings})
|
||||
if self.fail_times > 0:
|
||||
self.fail_times -= 1
|
||||
raise RuntimeError("summarizer down")
|
||||
|
||||
class Turn:
|
||||
pass
|
||||
|
||||
t = Turn()
|
||||
t.text = self.text
|
||||
return t
|
||||
|
||||
|
||||
# -- trigger math -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_trigger_is_min_of_pct_and_cap():
|
||||
assert trigger_tokens(100_000) == 80_000
|
||||
assert trigger_tokens(1_000_000) == DEFAULT_CAP_TOKENS # the 250k cap wins
|
||||
assert trigger_tokens(None) == int(0.8 * DEFAULT_CONTEXT_WINDOW)
|
||||
# both knobs are user-overridable
|
||||
assert trigger_tokens(100_000, threshold_pct=0.5, cap_tokens=40_000) == 40_000
|
||||
assert trigger_tokens(100_000, threshold_pct=0.5, cap_tokens=999_999) == 50_000
|
||||
|
||||
|
||||
def test_should_compact_crosses_threshold():
|
||||
assert not should_compact(79_999, 100_000)
|
||||
assert should_compact(80_000, 100_000)
|
||||
|
||||
|
||||
def test_estimate_tokens_is_chars_over_four():
|
||||
msgs = [user("a" * 400)]
|
||||
est = estimate_tokens(msgs)
|
||||
assert 100 <= est <= 120 # 400 chars of content + json overhead, /4
|
||||
|
||||
|
||||
# -- boundary -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_boundary_prefers_earliest_user_turn_that_fits():
|
||||
msgs = convo(turns=6)
|
||||
per_turn = estimate_tokens(msgs[1:3])
|
||||
boundary = pick_boundary(msgs, keep_tokens=per_turn * 2 + 10)
|
||||
assert msgs[boundary]["role"] == "user"
|
||||
assert msgs[boundary]["content"] == "request 4" # newest two turns survive
|
||||
|
||||
|
||||
def test_boundary_falls_inside_a_giant_final_turn():
|
||||
# One user turn followed by a huge tool loop: the turn alone exceeds the budget,
|
||||
# so the cut lands on an assistant (iteration) boundary inside it — never a tool row.
|
||||
msgs = [{"role": "system", "content": "s"}, user("go")]
|
||||
for i in range(8):
|
||||
a = assistant("step " + "y" * 3000, tool_calls=[("run_shell", {"command": f"cmd{i}"})])
|
||||
msgs += [a, tool(a["tool_calls"][0]["id"], {"exit_code": 0, "out": "z" * 3000})]
|
||||
boundary = pick_boundary(msgs, keep_tokens=estimate_tokens(msgs[-3:]))
|
||||
assert msgs[boundary]["role"] == "assistant"
|
||||
|
||||
|
||||
def test_boundary_none_when_nothing_to_summarize():
|
||||
msgs = [{"role": "system", "content": "s"}, user("hi"), assistant("hello")]
|
||||
assert pick_boundary(msgs, keep_tokens=10_000_000) is None
|
||||
|
||||
|
||||
# -- mechanical extraction ----------------------------------------------------
|
||||
|
||||
|
||||
def test_working_state_files_commands_tools():
|
||||
span = [
|
||||
user("write it"),
|
||||
*tool_turn("write_file", {"path": "a.py", "content": "x"}, {"ok": True}),
|
||||
*tool_turn("run_shell", {"command": "pytest -q"}, {"exit_code": 1}),
|
||||
*tool_turn("write_file", {"path": "b.py", "content": "y"}, {"ok": True}),
|
||||
*tool_turn("write_file", {"path": "a.py", "content": "x2"}, {"ok": True}),
|
||||
]
|
||||
block = extract_working_state(span)
|
||||
# deduped, most recent first
|
||||
assert block.index("- a.py") < block.index("- b.py")
|
||||
assert block.count("a.py") == 1
|
||||
assert "pytest -q" in block and "[exit 1]" in block
|
||||
assert "run_shell" in block and "write_file" in block
|
||||
|
||||
|
||||
def test_working_state_empty_span():
|
||||
assert extract_working_state([user("hi"), assistant("yo")]) == ""
|
||||
|
||||
|
||||
def test_user_messages_extracted_verbatim_and_clipped():
|
||||
span = [
|
||||
user("first ask"),
|
||||
assistant("a"),
|
||||
user([{"type": "text", "text": "second"}, {"type": "image_url", "image_url": {}}]),
|
||||
assistant("b"),
|
||||
user("bulk " + "z" * 2000),
|
||||
]
|
||||
out = extract_user_messages(span)
|
||||
assert out[0] == "first ask"
|
||||
assert out[1] == "second [image]"
|
||||
assert out[2].endswith("…") and len(out[2]) <= 600
|
||||
|
||||
|
||||
# -- summarizer seam ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_summarizer_messages_clip_tool_results_and_fold_prior():
|
||||
span = [user("go"), *tool_turn("read_file", {"path": "big.txt"}, "huge " * 500)]
|
||||
msgs = summarizer_messages(span, prior_summary="OLD SUMMARY")
|
||||
body = msgs[1]["content"]
|
||||
assert "OLD SUMMARY" in body
|
||||
assert len(body) < 3000 # the 2500-char tool result got clipped hard
|
||||
assert msgs[0]["role"] == "system" and "Primary request and intent" in msgs[0]["content"]
|
||||
|
||||
|
||||
def test_summarize_span_passes_model_and_raises_on_empty():
|
||||
fake = FakeSummarizer(text="## ok")
|
||||
out = summarize_span(fake, "prov:model-x", [user("hi")])
|
||||
assert out == "## ok"
|
||||
assert fake.calls[0]["model"] == "prov:model-x"
|
||||
assert fake.calls[0]["tools"] is None
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
summarize_span(FakeSummarizer(text=" "), "m", [user("hi")])
|
||||
|
||||
|
||||
# -- build + repeated compaction ----------------------------------------------
|
||||
|
||||
|
||||
def test_build_state_and_outbound_view():
|
||||
msgs = convo(turns=6)
|
||||
fake = FakeSummarizer(text="## Summary\nthe gist")
|
||||
state = build_state(
|
||||
msgs, provider=fake, model="m", keep_tokens=estimate_tokens(msgs[-4:]) + 10
|
||||
)
|
||||
assert state is not None and not state.trimmed
|
||||
assert state.user_messages[0] == "request 0"
|
||||
|
||||
out = apply_to_outbound(msgs, state)
|
||||
assert out[0]["role"] == "system" # instructions survive
|
||||
assert "<compacted-history>" in out[1]["content"]
|
||||
assert "the gist" in out[1]["content"]
|
||||
assert "request 0" in out[1]["content"] # mechanical user-message list
|
||||
assert out[2] is msgs[state.boundary_index] # verbatim tail, canonical untouched
|
||||
assert len(msgs) == 13 # canonical history unchanged
|
||||
|
||||
|
||||
def test_repeated_compaction_summarizes_prior_plus_new_turns():
|
||||
msgs = convo(turns=4)
|
||||
fake = FakeSummarizer()
|
||||
first = build_state(msgs, provider=fake, model="m", keep_tokens=estimate_tokens(msgs[-4:]) + 10)
|
||||
# session grows
|
||||
for i in range(4, 8):
|
||||
msgs.append(user(f"request {i}"))
|
||||
msgs.append(assistant(f"answer {i} " + "x" * 2000))
|
||||
second = build_state(
|
||||
msgs, provider=fake, model="m",
|
||||
keep_tokens=estimate_tokens(msgs[-4:]) + 10, prior=first,
|
||||
)
|
||||
assert second is not None and second.boundary_index > first.boundary_index
|
||||
# the second summarizer call folds the prior summary in
|
||||
assert "previous compaction summary" in fake.calls[1]["messages"][1]["content"]
|
||||
# user messages accumulate across compactions
|
||||
assert "request 0" in second.user_messages[0]
|
||||
assert any("request 5" in u for u in second.user_messages)
|
||||
|
||||
|
||||
def test_build_state_none_when_boundary_stale():
|
||||
msgs = convo(turns=3)
|
||||
fake = FakeSummarizer()
|
||||
state = build_state(msgs, provider=fake, model="m", keep_tokens=estimate_tokens(msgs[-2:]) + 10)
|
||||
again = build_state(
|
||||
msgs, provider=fake, model="m",
|
||||
keep_tokens=10_000_000, prior=state,
|
||||
)
|
||||
assert again is None # nothing new fits below the prior boundary
|
||||
|
||||
|
||||
# -- trim fallback ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_trim_advances_boundary_and_keeps_user_messages():
|
||||
msgs = convo(turns=10)
|
||||
state = trim_state(msgs)
|
||||
assert state is not None and state.trimmed
|
||||
assert msgs[state.boundary_index]["role"] in ("user", "assistant")
|
||||
assert state.user_messages # preserved mechanically even without a summary
|
||||
assert "trimmed" in state.summary_text
|
||||
out = apply_to_outbound(msgs, state)
|
||||
assert len(out) < len(msgs) + 1
|
||||
|
||||
|
||||
def test_trim_from_prior_state_never_lands_on_tool_row():
|
||||
msgs = [{"role": "system", "content": "s"}, user("go")]
|
||||
for i in range(10):
|
||||
msgs += tool_turn("run_shell", {"command": f"c{i}"}, {"exit_code": 0})
|
||||
prior = trim_state(msgs)
|
||||
later = trim_state(msgs, prior=prior)
|
||||
assert later.boundary_index > prior.boundary_index
|
||||
assert msgs[later.boundary_index]["role"] != "tool"
|
||||
|
||||
|
||||
def test_trim_none_when_too_small():
|
||||
assert trim_state([user("hi"), assistant("yo")]) is None
|
||||
|
||||
|
||||
# -- state round-trip + overflow detection ------------------------------------
|
||||
|
||||
|
||||
def test_state_dict_round_trip():
|
||||
state = CompactionState(
|
||||
boundary_index=7, summary_text="s", working_state="w",
|
||||
user_messages=["u1"], created_at=1.5, model_used="m", trimmed=True,
|
||||
)
|
||||
assert CompactionState.from_dict(state.as_dict()) == state
|
||||
assert CompactionState.from_dict(None) is None
|
||||
assert CompactionState.from_dict({}) is None
|
||||
|
||||
|
||||
def test_apply_to_outbound_noop_on_stale_or_missing_state():
|
||||
msgs = convo(turns=2)
|
||||
assert apply_to_outbound(msgs, None) is msgs
|
||||
stale = CompactionState(boundary_index=999, summary_text="s", working_state="")
|
||||
assert apply_to_outbound(msgs, stale) is msgs
|
||||
|
||||
|
||||
def test_is_context_overflow():
|
||||
assert is_context_overflow(Exception("Error 400: maximum context length is 128000 tokens"))
|
||||
assert is_context_overflow(Exception("context_length_exceeded"))
|
||||
assert is_context_overflow(Exception("Prompt is too long: 210000 tokens > limit"))
|
||||
assert not is_context_overflow(Exception("rate limit exceeded"))
|
||||
assert not is_context_overflow(Exception("connection reset"))
|
||||
|
||||
|
||||
def test_user_messages_capped_across_repeated_compactions():
|
||||
# The mechanical user-message list must not grow forever — newest _USER_MESSAGES_MAX
|
||||
# survive, the rest stay counted so the block's "omitted" note is honest.
|
||||
from coworker.compaction import _USER_MESSAGES_MAX
|
||||
|
||||
msgs = [{"role": "system", "content": "s"}]
|
||||
for i in range(120):
|
||||
msgs.append({"role": "user", "content": f"ask {i}"})
|
||||
msgs.append({"role": "assistant", "content": f"answer {i}"})
|
||||
|
||||
state = None
|
||||
while True:
|
||||
nxt = trim_state(msgs, prior=state, fraction=0.4)
|
||||
if nxt is None:
|
||||
break
|
||||
state = nxt
|
||||
|
||||
assert state is not None
|
||||
assert len(state.user_messages) <= _USER_MESSAGES_MAX
|
||||
assert state.user_messages_dropped > 0
|
||||
assert state.user_messages[-1].startswith("ask") # newest survive, oldest dropped
|
||||
|
||||
block = compacted_block(state)
|
||||
assert f"{state.user_messages_dropped} earlier user messages omitted" in block
|
||||
|
||||
restored = CompactionState.from_dict(state.as_dict())
|
||||
assert restored is not None
|
||||
assert restored.user_messages_dropped == state.user_messages_dropped
|
||||
assert restored.user_messages == state.user_messages
|
||||
@@ -0,0 +1,275 @@
|
||||
"""OPE-27 engine hook: the mid-run trigger, the outbound view, the usage signal, the
|
||||
failure policy (attended prompt / unattended auto-trim), raw-overflow routing, and the
|
||||
session persistence round-trip. Scripted providers, tiny forced windows, no network."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from coworker.engine import TurnEngine
|
||||
from coworker.events import EventType
|
||||
from coworker.permissions import PermissionEngine
|
||||
from coworker.providers import (
|
||||
AssistantTurn,
|
||||
ModelCapabilities,
|
||||
ProviderClient,
|
||||
ToolCall,
|
||||
)
|
||||
from coworker.providers.base import TokenUsage
|
||||
from coworker.tools import ToolRegistry
|
||||
|
||||
SUMMARY = "## Primary request and intent\nkeep building the report"
|
||||
|
||||
|
||||
class CompactingProvider(ProviderClient):
|
||||
"""Scripted main turns; summarizer calls (recognized by the compaction system prompt)
|
||||
are answered out-of-band so they never consume the main script."""
|
||||
|
||||
def __init__(self, turns, *, summary=SUMMARY, summary_fails=0, main_overflows=0):
|
||||
self._turns = list(turns)
|
||||
self.summary = summary
|
||||
self.summary_fails = summary_fails
|
||||
self.main_overflows = main_overflows
|
||||
self.summary_calls = []
|
||||
self.main_calls = 0
|
||||
|
||||
def complete(self, *, model, messages, tools=None, **settings):
|
||||
if messages and "compacting an AI coworker" in str(
|
||||
messages[0].get("content", "")
|
||||
):
|
||||
self.summary_calls.append({"model": model, "messages": messages})
|
||||
if self.summary_fails > 0:
|
||||
self.summary_fails -= 1
|
||||
raise RuntimeError("summarizer down")
|
||||
return AssistantTurn(text=self.summary, finish_reason="stop")
|
||||
self.main_calls += 1
|
||||
if self.main_overflows > 0:
|
||||
self.main_overflows -= 1
|
||||
raise RuntimeError(
|
||||
"Error 400: maximum context length is 100000 tokens, request used more"
|
||||
)
|
||||
return self._turns.pop(0)
|
||||
|
||||
def capabilities(self, model):
|
||||
return ModelCapabilities()
|
||||
|
||||
|
||||
def long_history(turns=8, bulk=1500):
|
||||
msgs = [{"role": "system", "content": "be helpful"}]
|
||||
for i in range(turns):
|
||||
msgs.append({"role": "user", "content": f"request {i}", "ts": 1.0})
|
||||
msgs.append(
|
||||
{"role": "assistant", "content": f"answer {i} " + "x" * bulk, "ts": 1.0}
|
||||
)
|
||||
return msgs
|
||||
|
||||
|
||||
def make_engine(tmp_path, provider, *, messages=None, cap=400):
|
||||
engine = TurnEngine(
|
||||
provider=provider,
|
||||
registry=ToolRegistry(),
|
||||
permissions=PermissionEngine(workspace_root=tmp_path),
|
||||
model="gpt-5.5",
|
||||
messages=messages,
|
||||
)
|
||||
engine.compaction_settings = lambda: {
|
||||
"cap_tokens": cap,
|
||||
"threshold_pct": 0.8,
|
||||
"context_window": 100_000,
|
||||
}
|
||||
return engine
|
||||
|
||||
|
||||
def collect(engine, text="continue"):
|
||||
async def _run():
|
||||
return [e async for e in engine.run(text)]
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
def test_compacts_before_the_turn_when_estimate_crosses(tmp_path):
|
||||
provider = CompactingProvider([AssistantTurn(text="done", finish_reason="stop")])
|
||||
engine = make_engine(tmp_path,provider, messages=long_history(), cap=400)
|
||||
events = collect(engine)
|
||||
|
||||
assert any(e.type == EventType.COMPACTED for e in events)
|
||||
assert not any(e.type == EventType.ERROR for e in events)
|
||||
state = engine.compaction_state
|
||||
assert state is not None and not state.trimmed
|
||||
assert provider.summary_calls[0]["model"] == "gpt-5.5" # session's own model
|
||||
|
||||
# Outbound view: system survives, the block stands in for the old turns, the
|
||||
# canonical transcript is untouched, and the persisted notice marks the spot.
|
||||
out = engine._outbound_messages()
|
||||
assert out[0]["role"] == "system"
|
||||
assert "<compacted-history>" in out[1]["content"]
|
||||
assert SUMMARY.splitlines()[-1] in out[1]["content"]
|
||||
assert "request 0" in out[1]["content"] # mechanical user-message list
|
||||
assert any("answer 0" in str(m.get("content")) for m in engine.messages)
|
||||
assert any(
|
||||
m.get("role") == "notice" and m.get("kind") == "compacted"
|
||||
for m in engine.messages
|
||||
)
|
||||
|
||||
|
||||
def test_usage_signal_triggers_between_tool_turns(tmp_path):
|
||||
# History too small for the estimate path — only the reported usage crosses the
|
||||
# trigger, after iteration 1's round-trip. The compaction runs before iteration 2.
|
||||
provider = CompactingProvider(
|
||||
[
|
||||
AssistantTurn(
|
||||
tool_calls=[ToolCall(id="c1", name="nonexistent_tool", arguments={})],
|
||||
finish_reason="tool_calls",
|
||||
usage=TokenUsage(input=90_000, output=10),
|
||||
),
|
||||
AssistantTurn(text="done", finish_reason="stop"),
|
||||
]
|
||||
)
|
||||
engine = make_engine(tmp_path,provider, messages=long_history(turns=2, bulk=10), cap=400)
|
||||
events = collect(engine)
|
||||
assert any(e.type == EventType.COMPACTED for e in events)
|
||||
assert provider.summary_calls # driven by usage, not the (tiny) estimate
|
||||
assert engine._last_context_tokens is None # reset once the view shrank
|
||||
|
||||
|
||||
def test_summarizer_failure_unattended_auto_trims(tmp_path):
|
||||
provider = CompactingProvider(
|
||||
[AssistantTurn(text="done", finish_reason="stop")], summary_fails=99
|
||||
)
|
||||
engine = make_engine(tmp_path,provider, messages=long_history(), cap=400)
|
||||
events = collect(engine) # is_attended is None → unattended policy
|
||||
|
||||
compacted = [e for e in events if e.type == EventType.COMPACTED]
|
||||
assert compacted and "trimmed" in compacted[0].data["text"].lower()
|
||||
assert engine.compaction_state is not None and engine.compaction_state.trimmed
|
||||
assert len(provider.summary_calls) == 2 # the one unconditional retry, then trim
|
||||
|
||||
|
||||
def test_summarizer_failure_attended_prompts_retry_then_succeeds(tmp_path):
|
||||
provider = CompactingProvider(
|
||||
[AssistantTurn(text="done", finish_reason="stop")], summary_fails=2
|
||||
)
|
||||
engine = make_engine(tmp_path,provider, messages=long_history(), cap=400)
|
||||
engine.is_attended = lambda: True
|
||||
asked = []
|
||||
|
||||
async def asker(args, tool_call_id=None):
|
||||
asked.append(args)
|
||||
return {"answer": "Retry"}
|
||||
|
||||
engine.question_asker = asker
|
||||
collect(engine)
|
||||
|
||||
assert asked and asked[0]["options"] == ["Retry", "Trim oldest 10%"]
|
||||
assert engine.compaction_state is not None and not engine.compaction_state.trimmed
|
||||
|
||||
|
||||
def test_summarizer_failure_attended_choose_trim(tmp_path):
|
||||
provider = CompactingProvider(
|
||||
[AssistantTurn(text="done", finish_reason="stop")], summary_fails=99
|
||||
)
|
||||
engine = make_engine(tmp_path,provider, messages=long_history(), cap=400)
|
||||
engine.is_attended = lambda: True
|
||||
|
||||
async def asker(args, tool_call_id=None):
|
||||
return {"answer": "Trim oldest 10%"}
|
||||
|
||||
engine.question_asker = asker
|
||||
collect(engine)
|
||||
assert engine.compaction_state is not None and engine.compaction_state.trimmed
|
||||
|
||||
|
||||
def test_raw_overflow_routes_into_compaction_and_retries(tmp_path):
|
||||
# Trigger never fires (huge cap) — the provider 400 is the only signal. The engine
|
||||
# must compact (force) and retry the call instead of surfacing the error.
|
||||
provider = CompactingProvider(
|
||||
[AssistantTurn(text="recovered", finish_reason="stop")], main_overflows=1
|
||||
)
|
||||
engine = make_engine(tmp_path,provider, messages=long_history(), cap=1_000_000)
|
||||
events = collect(engine)
|
||||
|
||||
assert any(e.type == EventType.COMPACTED for e in events)
|
||||
assert not any(e.type == EventType.ERROR for e in events)
|
||||
finals = [e for e in events if e.type == EventType.ASSISTANT_MESSAGE]
|
||||
assert finals and finals[-1].data["text"] == "recovered"
|
||||
assert provider.main_calls == 2
|
||||
|
||||
|
||||
def test_non_overflow_provider_errors_still_surface(tmp_path):
|
||||
class FailingProvider(CompactingProvider):
|
||||
def complete(self, *, model, messages, tools=None, **settings):
|
||||
raise RuntimeError("rate limit exceeded")
|
||||
|
||||
engine = make_engine(tmp_path,FailingProvider([]), messages=long_history(turns=1), cap=1_000_000)
|
||||
events = collect(engine)
|
||||
assert any(e.type == EventType.ERROR for e in events)
|
||||
assert not any(e.type == EventType.COMPACTED for e in events)
|
||||
|
||||
|
||||
def test_set_compaction_settings_validates_and_round_trips(tmp_path):
|
||||
from coworker.server.manager import SessionManager
|
||||
|
||||
class Provider(ProviderClient):
|
||||
def complete(self, *, model, messages, tools=None, **settings):
|
||||
return AssistantTurn(text="hi")
|
||||
|
||||
def capabilities(self, model):
|
||||
return ModelCapabilities()
|
||||
|
||||
mgr = SessionManager(workspace=tmp_path, provider=Provider())
|
||||
out = mgr.set_compaction_settings(
|
||||
threshold_pct=0.5, cap_tokens=100_000, model="gpt-4o-mini"
|
||||
)
|
||||
assert out["ok"] and out["threshold_pct"] == 0.5 and out["cap_tokens"] == 100_000
|
||||
assert mgr.compaction_settings()["model"] == "gpt-4o-mini"
|
||||
# validation: out-of-range % and non-numeric cap are rejected, tiny caps clamp up
|
||||
assert mgr.set_compaction_settings(threshold_pct=0.05)["ok"] is False
|
||||
assert mgr.set_compaction_settings(cap_tokens="lots")["ok"] is False
|
||||
assert mgr.set_compaction_settings(cap_tokens=1)["cap_tokens"] == 10_000
|
||||
# the flat /v1/settings names
|
||||
payload = mgr.compaction_settings_payload()
|
||||
assert payload["compaction_threshold_pct"] == 0.5
|
||||
assert payload["compaction_model"] == "gpt-4o-mini"
|
||||
|
||||
|
||||
def test_compaction_state_survives_save_and_rebuild(tmp_path):
|
||||
from coworker.compaction import CompactionState
|
||||
from coworker.server.manager import SessionManager
|
||||
|
||||
class Provider(ProviderClient):
|
||||
def complete(self, *, model, messages, tools=None, **settings):
|
||||
return AssistantTurn(text="hi", finish_reason="stop")
|
||||
|
||||
def capabilities(self, model):
|
||||
return ModelCapabilities()
|
||||
|
||||
mgr = SessionManager(workspace=tmp_path, provider=Provider())
|
||||
sid = "compact-persist"
|
||||
engine = mgr.get_engine(sid, agent="cowork", workspace=str(tmp_path))
|
||||
assert callable(engine.compaction_settings) # live Settings getter is wired
|
||||
assert engine.compaction_settings()["threshold_pct"] == 0.8
|
||||
|
||||
engine.messages += long_history(turns=3)[1:]
|
||||
engine.compaction_state = CompactionState(
|
||||
boundary_index=3, summary_text="the gist", working_state="", user_messages=["u"]
|
||||
)
|
||||
mgr.save(sid, engine)
|
||||
mgr._engines.pop(sid)
|
||||
|
||||
rebuilt = mgr.get_engine(sid, agent="cowork", workspace=str(tmp_path))
|
||||
assert rebuilt.compaction_state == engine.compaction_state
|
||||
|
||||
|
||||
def test_compacting_signal_precedes_the_compacted_marker(tmp_path):
|
||||
# The transient-progress contract: COMPACTING fires before the (slow) summarizer
|
||||
# call, COMPACTED after — surfaces key the "Compacting context…" spinner on it.
|
||||
provider = CompactingProvider([AssistantTurn(text="done", finish_reason="stop")])
|
||||
engine = make_engine(tmp_path, provider, messages=long_history(), cap=400)
|
||||
events = collect(engine)
|
||||
|
||||
types = [e.type for e in events]
|
||||
assert EventType.COMPACTING in types
|
||||
assert types.index(EventType.COMPACTING) < types.index(EventType.COMPACTED)
|
||||
# The signal is not persisted — only the compacted marker lands in the transcript.
|
||||
assert not any(
|
||||
m.get("role") == "notice" and m.get("kind") == "compacting"
|
||||
for m in engine.messages
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""OPE-27 smoke (4/4) — a long multi-turn session driven through the real SessionManager
|
||||
across REPEATED forced compactions: the provider must actually receive the compacted
|
||||
view (summary block + verbatim tail), user intent must survive every compaction, and the
|
||||
state must survive a save/rebuild mid-conversation. This is the scripted stand-in for
|
||||
the live-model smoke (which needs a configured provider key)."""
|
||||
|
||||
import json
|
||||
|
||||
import asyncio
|
||||
|
||||
from coworker.providers import AssistantTurn, ModelCapabilities, ProviderClient
|
||||
from coworker.providers.base import TokenUsage
|
||||
from coworker.server.manager import SessionManager
|
||||
|
||||
BULK = "analysis paragraph " * 400 # ~7.6k chars (~1.9k tokens) per turn → triggers by turn 2
|
||||
|
||||
|
||||
class LongSessionProvider(ProviderClient):
|
||||
"""Main turns: bulky text answers with realistic (growing) usage reporting.
|
||||
Summarizer turns: a structured summary echoing the required sections."""
|
||||
|
||||
def __init__(self):
|
||||
self.main_messages_seen: list[list[dict]] = []
|
||||
self.summary_prompts: list[str] = []
|
||||
|
||||
def complete(self, *, model, messages, tools=None, **settings):
|
||||
if messages and "compacting an AI coworker" in str(
|
||||
messages[0].get("content", "")
|
||||
):
|
||||
self.summary_prompts.append(str(messages[1]["content"]))
|
||||
return AssistantTurn(
|
||||
text=(
|
||||
"## Primary request and intent\nBuild the Q3 report; never email "
|
||||
"it without approval.\n## Current work\nDrafting section "
|
||||
f"{len(self.summary_prompts)}.\n## Next step\nContinue drafting."
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
self.main_messages_seen.append([dict(m) for m in messages])
|
||||
# Usage mirrors the outbound size (chars/4), like a real provider would bill it.
|
||||
prompt_tokens = sum(len(json.dumps(m, default=str)) for m in messages) // 4
|
||||
return AssistantTurn(
|
||||
text=f"turn {len(self.main_messages_seen)}: {BULK}",
|
||||
finish_reason="stop",
|
||||
usage=TokenUsage(input=prompt_tokens, output=500),
|
||||
)
|
||||
|
||||
def capabilities(self, model):
|
||||
return ModelCapabilities()
|
||||
|
||||
|
||||
def test_long_session_survives_repeated_compaction(tmp_path):
|
||||
provider = LongSessionProvider()
|
||||
mgr = SessionManager(workspace=tmp_path, provider=provider)
|
||||
# Force tiny windows straight through the real Settings plumbing.
|
||||
mgr._prefs["compaction_cap_tokens"] = 3_000
|
||||
sid = "smoke-long"
|
||||
|
||||
boundaries = []
|
||||
|
||||
# ONE event loop for the whole session, like the real server — the engine's asyncio
|
||||
# primitives bind to the loop they first run on, so a per-turn asyncio.run() would
|
||||
# silently drop every stream after the first (found the hard way in the live smoke).
|
||||
async def scenario():
|
||||
engine = mgr.get_engine(sid, agent="cowork", workspace=str(tmp_path))
|
||||
for i in range(8):
|
||||
async for _ in engine.run(f"user step {i}: keep drafting the Q3 report"):
|
||||
pass
|
||||
# Every turn must produce a real reply — an empty assistant message means
|
||||
# the stream got dropped, not answered.
|
||||
last = next(
|
||||
m for m in reversed(engine.messages) if m.get("role") == "assistant"
|
||||
)
|
||||
assert f"turn {i + 1}:" in str(last.get("content", ""))
|
||||
if engine.compaction_state is not None:
|
||||
if (
|
||||
not boundaries
|
||||
or engine.compaction_state.boundary_index != boundaries[-1]
|
||||
):
|
||||
boundaries.append(engine.compaction_state.boundary_index)
|
||||
mgr.save(sid, engine)
|
||||
if i == 4: # mid-conversation restart: state must survive the rebuild
|
||||
mgr._engines.pop(sid)
|
||||
engine = mgr.get_engine(sid, agent="cowork", workspace=str(tmp_path))
|
||||
assert engine.compaction_state is not None
|
||||
return engine
|
||||
|
||||
engine = asyncio.run(scenario())
|
||||
|
||||
# Repeated compaction actually happened, moving forward each time.
|
||||
assert len(boundaries) >= 2
|
||||
assert boundaries == sorted(boundaries)
|
||||
# Later summarizer calls fold the previous summary in (summary is message zero).
|
||||
assert any("previous compaction summary" in p for p in provider.summary_prompts)
|
||||
|
||||
# What the MODEL actually received after the last compaction: the block + the tail,
|
||||
# bounded — not the whole ever-growing canonical history.
|
||||
final_view = provider.main_messages_seen[-1]
|
||||
assert final_view[0]["role"] == "system"
|
||||
block = final_view[1]["content"]
|
||||
assert "<compacted-history>" in block
|
||||
assert "Q3 report" in block # the summary carries the intent
|
||||
assert "user step 0" in block # mechanical user-message preservation, from turn 0
|
||||
assert "do not recap" in block # the continuation contract
|
||||
assert len(final_view) < len(engine.messages)
|
||||
|
||||
# Canonical transcript: untouched (every turn still present) + the divider notices.
|
||||
texts = [str(m.get("content", "")) for m in engine.messages]
|
||||
assert all(any(f"user step {i}" in t for t in texts) for i in range(8))
|
||||
assert sum(1 for m in engine.messages if m.get("kind") == "compacted") >= 2
|
||||
|
||||
# The persisted record round-trips the final state.
|
||||
record = mgr.session_store.load(sid)
|
||||
assert record.compaction["boundary_index"] == engine.compaction_state.boundary_index
|
||||
@@ -88,3 +88,37 @@ def test_inbound_legacy_ocw_token_still_resolves(tmp_path):
|
||||
item = store.add_approval("s1", "Deploy?", inbox="ops")
|
||||
assert resolve_from_reply(f"deny [ocw:{item.id}]", store.resolve) is True
|
||||
assert store.get(item.id).resolution == "deny"
|
||||
|
||||
|
||||
def test_disallow_is_not_parsed_as_allow(tmp_path):
|
||||
store = InboxStore(tmp_path / "inbox.json")
|
||||
item = store.add_approval("s1", "Deploy?", inbox="ops")
|
||||
assert resolve_from_reply(f"disallow [ow:{item.id}]", store.resolve) is True
|
||||
assert store.get(item.id).resolution != "allow"
|
||||
|
||||
|
||||
def test_words_containing_no_are_not_parsed_as_deny(tmp_path):
|
||||
store = InboxStore(tmp_path / "inbox.json")
|
||||
q = store.add_question("s1", "Which region?")
|
||||
assert resolve_from_reply(f"north-east node [ow:{q.id}]", store.resolve) is True
|
||||
assert store.get(q.id).resolution == "north-east node"
|
||||
|
||||
|
||||
def test_denied_and_approved_word_forms(tmp_path):
|
||||
store = InboxStore(tmp_path / "inbox.json")
|
||||
a = store.add_approval("s1", "Deploy?", inbox="ops")
|
||||
b = store.add_approval("s1", "Restart?", inbox="ops")
|
||||
resolve_from_reply(f"denied [ow:{a.id}]", store.resolve)
|
||||
resolve_from_reply(f"approved [ow:{b.id}]", store.resolve)
|
||||
assert store.get(a.id).resolution == "deny"
|
||||
assert store.get(b.id).resolution == "allow"
|
||||
|
||||
|
||||
def test_emoji_reactions_still_resolve(tmp_path):
|
||||
store = InboxStore(tmp_path / "inbox.json")
|
||||
a = store.add_approval("s1", "Deploy?", inbox="ops")
|
||||
b = store.add_approval("s1", "Restart?", inbox="ops")
|
||||
resolve_from_reply(f"👍 [ow:{a.id}]", store.resolve)
|
||||
resolve_from_reply(f"❌ [ow:{b.id}]", store.resolve)
|
||||
assert store.get(a.id).resolution == "allow"
|
||||
assert store.get(b.id).resolution == "deny"
|
||||
|
||||
+97
-6
@@ -55,21 +55,112 @@ def test_load_merges_global_and_workspace(tmp_path, monkeypatch):
|
||||
ws / ".coworker" / "mcp.json",
|
||||
{
|
||||
"mcpServers": {
|
||||
"fs": {
|
||||
"command": "echo",
|
||||
"args": ["workspace-wins"],
|
||||
}, # overrides global
|
||||
"fs": {"command": "echo", "args": ["workspace-loses"]}, # clashes: global wins
|
||||
"ws_only": {"command": "echo", "args": ["ws"], "enabled": True},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
servers = {s.name: s for s in load_mcp_servers(ws, secrets=SecretStore())}
|
||||
assert servers["fs"].args == ["workspace-wins"]
|
||||
servers = {
|
||||
s.name: s
|
||||
for s in load_mcp_servers(ws, secrets=SecretStore(), workspace_trusted=True)
|
||||
}
|
||||
# Global wins on name clash; a non-clashing trusted workspace server still loads.
|
||||
assert servers["fs"].args == ["global"]
|
||||
assert servers["ws_only"].args == ["ws"]
|
||||
assert servers["fs"].transport == "stdio"
|
||||
assert servers["docs"].transport == "http" and servers["docs"].enabled is False
|
||||
assert servers["docs"].requires_approval is True # default
|
||||
|
||||
|
||||
def test_untrusted_workspace_mcp_ignored(tmp_path, monkeypatch):
|
||||
"""#213: a cloned repo's `.coworker/mcp.json` must not load until trust."""
|
||||
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
|
||||
_write_json(
|
||||
tmp_path / "state" / "mcp.json",
|
||||
{
|
||||
"mcpServers": {
|
||||
"fs": {"command": "echo", "args": ["global"], "enabled": True},
|
||||
}
|
||||
},
|
||||
)
|
||||
ws = tmp_path / "ws"
|
||||
_write_json(
|
||||
ws / ".coworker" / "mcp.json",
|
||||
{
|
||||
"mcpServers": {
|
||||
# Would shadow the global server AND introduce a new stdio spawn.
|
||||
"fs": {"command": "echo", "args": ["pwned"]},
|
||||
"evil": {
|
||||
"command": "/bin/sh",
|
||||
"args": ["-c", "echo PWNED"],
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Default / explicit untrusted: global only; no name hijack, no evil server.
|
||||
for kwargs in ({}, {"workspace_trusted": False}):
|
||||
servers = {
|
||||
s.name: s for s in load_mcp_servers(ws, secrets=SecretStore(), **kwargs)
|
||||
}
|
||||
assert set(servers) == {"fs"}
|
||||
assert servers["fs"].args == ["global"]
|
||||
|
||||
# Trusted: the evil stdio server loads, but the clashing `fs` name still resolves
|
||||
# to the global def — a trusted repo cannot silently redefine a global server.
|
||||
trusted = {
|
||||
s.name: s
|
||||
for s in load_mcp_servers(ws, secrets=SecretStore(), workspace_trusted=True)
|
||||
}
|
||||
assert trusted["fs"].args == ["global"]
|
||||
assert "evil" in trusted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_mcp_tools_does_not_spawn_untrusted_workspace(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""End-to-end for #213: untrusted workspace MCP never reaches MCPManager.ensure."""
|
||||
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
|
||||
ws = tmp_path / "cloned-repo"
|
||||
_write_json(
|
||||
ws / ".coworker" / "mcp.json",
|
||||
{
|
||||
"mcpServers": {
|
||||
"totally-normal-tool": {
|
||||
"command": "/bin/sh",
|
||||
"args": ["-c", "echo PWNED"],
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
manager = SessionManager(data_dir=tmp_path / "data")
|
||||
ensure_calls: list[str] = []
|
||||
|
||||
async def _boom(server, *, interactive: bool = False):
|
||||
ensure_calls.append(server.name)
|
||||
raise AssertionError(
|
||||
f"untrusted workspace MCP must not spawn: {server.name!r}"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(manager.mcp, "ensure", _boom)
|
||||
|
||||
tools = await manager.prepare_mcp_tools("s1", workspace=str(ws))
|
||||
assert tools == []
|
||||
assert ensure_calls == []
|
||||
assert manager.workspace_trust.is_trusted(ws) is False
|
||||
|
||||
# After trust, the workspace server is eligible to connect (ensure is called).
|
||||
manager.workspace_trust.set_trusted(ws, True)
|
||||
tools = await manager.prepare_mcp_tools("s2", workspace=str(ws))
|
||||
assert ensure_calls == ["totally-normal-tool"]
|
||||
assert tools == [] # ensure raised; no tools attached, but spawn was attempted
|
||||
|
||||
|
||||
def test_var_resolution(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
|
||||
monkeypatch.setenv("DOCS_TOKEN", "sekret")
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
"""OpenAI Responses provider — message/tool conversion, complete(), stream(), sidecar
|
||||
replay, param-fix retries. SDK-free: the fake client mimics the OpenAI SDK's
|
||||
`responses.create` surface with dicts/SimpleNamespace objects, the same pattern the
|
||||
Gemini/Anthropic provider tests use."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from coworker.providers.openai_responses import (
|
||||
OpenAIResponsesProvider,
|
||||
_param_fix_retry,
|
||||
convert_messages,
|
||||
convert_tools,
|
||||
)
|
||||
|
||||
# -- fakes ------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""Records the kwargs passed to responses.create; raises queued errors first (to
|
||||
exercise the param-fix retries), then returns the canned response — or, when the
|
||||
request asked for stream=True, an iterator of canned events."""
|
||||
|
||||
def __init__(self, response=None, events=None, errors=None):
|
||||
self.kwargs: dict = {}
|
||||
self.calls: list[dict] = []
|
||||
errors = list(errors or [])
|
||||
|
||||
def create(**kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.calls.append(kwargs)
|
||||
if errors:
|
||||
raise errors.pop(0)
|
||||
if kwargs.get("stream"):
|
||||
return iter(events or [])
|
||||
return response
|
||||
|
||||
self.responses = SimpleNamespace(create=create)
|
||||
|
||||
|
||||
def _response(output, status="completed", incomplete_details=None):
|
||||
return SimpleNamespace(
|
||||
output=output, status=status, incomplete_details=incomplete_details
|
||||
)
|
||||
|
||||
|
||||
def _message_item(text):
|
||||
return {
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": text}],
|
||||
}
|
||||
|
||||
|
||||
def _reasoning_item(summaries, encrypted="enc-blob"):
|
||||
item = {
|
||||
"type": "reasoning",
|
||||
"id": "rs_1",
|
||||
"summary": [{"type": "summary_text", "text": s} for s in summaries],
|
||||
}
|
||||
if encrypted:
|
||||
item["encrypted_content"] = encrypted
|
||||
return item
|
||||
|
||||
|
||||
def _call_item(call_id, name, arguments):
|
||||
return {
|
||||
"type": "function_call",
|
||||
"id": f"fc_{call_id}",
|
||||
"call_id": call_id,
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
}
|
||||
|
||||
|
||||
# -- message conversion -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_convert_extracts_leading_system_as_instructions():
|
||||
instructions, items = convert_messages(
|
||||
[
|
||||
{"role": "system", "content": "be helpful"},
|
||||
{"role": "system", "content": "be brief"},
|
||||
{"role": "user", "content": "hi"},
|
||||
]
|
||||
)
|
||||
assert instructions == "be helpful\n\nbe brief"
|
||||
assert items == [{"role": "user", "content": "hi"}]
|
||||
|
||||
|
||||
def test_convert_mid_thread_system_stays_a_message():
|
||||
_, items = convert_messages(
|
||||
[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "system", "content": "steering"},
|
||||
]
|
||||
)
|
||||
assert items[1] == {"role": "system", "content": "steering"}
|
||||
|
||||
|
||||
def test_convert_user_parts_to_input_parts():
|
||||
_, items = convert_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "what is this"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,iVBORw0KGgo="},
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"file": {
|
||||
"filename": "report.pdf",
|
||||
"file_data": "data:application/pdf;base64,JVBERi0=",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert items[0]["content"] == [
|
||||
{"type": "input_text", "text": "what is this"},
|
||||
{"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo="},
|
||||
{
|
||||
"type": "input_file",
|
||||
"filename": "report.pdf",
|
||||
"file_data": "data:application/pdf;base64,JVBERi0=",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_convert_synthesizes_assistant_and_tool_items():
|
||||
# No `_openai` sidecar (history from another provider): items are rebuilt from the
|
||||
# canonical fields, and foreign toolu_ ids still pair call → output.
|
||||
_, items = convert_messages(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "on it",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "toolu_abc",
|
||||
"type": "function",
|
||||
"function": {"name": "f", "arguments": '{"x": 1}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "toolu_abc", "content": '{"ok": true}'},
|
||||
]
|
||||
)
|
||||
assert items[1] == {"role": "assistant", "content": "on it"}
|
||||
assert items[2] == {
|
||||
"type": "function_call",
|
||||
"call_id": "toolu_abc",
|
||||
"name": "f",
|
||||
"arguments": '{"x": 1}',
|
||||
}
|
||||
assert items[3] == {
|
||||
"type": "function_call_output",
|
||||
"call_id": "toolu_abc",
|
||||
"output": '{"ok": true}',
|
||||
}
|
||||
|
||||
|
||||
def test_convert_replays_openai_sidecar_verbatim():
|
||||
sidecar_items = [
|
||||
_reasoning_item(["thinking"], encrypted="blob"),
|
||||
_message_item("on it"),
|
||||
_call_item("call_1", "f", "{}"),
|
||||
]
|
||||
_, items = convert_messages(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "on it",
|
||||
"tool_calls": [
|
||||
{"id": "call_1", "function": {"name": "f", "arguments": "{}"}}
|
||||
],
|
||||
"_openai": {"items": sidecar_items},
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "done"},
|
||||
]
|
||||
)
|
||||
# The sidecar items go in verbatim — no synthesized duplicates alongside.
|
||||
assert items[1:4] == sidecar_items
|
||||
assert items[4]["type"] == "function_call_output"
|
||||
|
||||
|
||||
def test_convert_ignores_foreign_sidecars():
|
||||
_, items = convert_messages(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "hi",
|
||||
"_gemini": {"text_sig": "abc"},
|
||||
},
|
||||
]
|
||||
)
|
||||
assert items[1] == {"role": "assistant", "content": "hi"}
|
||||
|
||||
|
||||
def test_convert_empty_assistant_tool_turn_emits_no_message_item():
|
||||
_, items = convert_messages(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "c1", "function": {"name": "f", "arguments": "{}"}}
|
||||
],
|
||||
},
|
||||
]
|
||||
)
|
||||
assert [i.get("type") for i in items[1:]] == ["function_call"]
|
||||
|
||||
|
||||
# -- tool schema conversion ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_convert_tools_flattens_function_schemas():
|
||||
tools = convert_tools(
|
||||
[
|
||||
{"type": "function", "function": {"name": "bare"}},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "full",
|
||||
"description": "does things",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"x": {"type": "integer"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
assert tools[0] == {"type": "function", "name": "bare"}
|
||||
assert tools[1]["name"] == "full" and "function" not in tools[1]
|
||||
assert tools[1]["parameters"]["properties"] == {"x": {"type": "integer"}}
|
||||
assert convert_tools(None) == []
|
||||
|
||||
|
||||
# -- complete() ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_complete_text_turn_and_request_shape():
|
||||
fake = _FakeClient(response=_response([_message_item("hello")]))
|
||||
provider = OpenAIResponsesProvider(client=fake)
|
||||
turn = provider.complete(
|
||||
model="gpt-5.6-sol",
|
||||
messages=[
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "hi"},
|
||||
],
|
||||
)
|
||||
assert turn.text == "hello" and turn.finish_reason == "stop"
|
||||
assert not turn.has_tool_calls and turn.extras == {}
|
||||
assert fake.kwargs["model"] == "gpt-5.6-sol"
|
||||
assert fake.kwargs["instructions"] == "sys"
|
||||
assert fake.kwargs["store"] is False
|
||||
assert fake.kwargs["include"] == ["reasoning.encrypted_content"]
|
||||
assert fake.kwargs["reasoning"] == {"summary": "auto"}
|
||||
|
||||
|
||||
def test_complete_parses_function_calls_with_call_ids():
|
||||
fake = _FakeClient(
|
||||
response=_response(
|
||||
[
|
||||
_message_item("on it"),
|
||||
_call_item("call_a", "write_file", '{"path": "a.txt"}'),
|
||||
_call_item("call_b", "read_file", "not json"),
|
||||
]
|
||||
)
|
||||
)
|
||||
provider = OpenAIResponsesProvider(client=fake)
|
||||
turn = provider.complete(model="m", messages=[{"role": "user", "content": "go"}])
|
||||
assert turn.text == "on it" and turn.finish_reason == "tool_calls"
|
||||
assert [(c.id, c.name) for c in turn.tool_calls] == [
|
||||
("call_a", "write_file"),
|
||||
("call_b", "read_file"),
|
||||
]
|
||||
assert turn.tool_calls[0].arguments == {"path": "a.txt"}
|
||||
assert turn.tool_calls[1].arguments == {"_raw": "not json"}
|
||||
|
||||
|
||||
def test_complete_surfaces_reasoning_summary_and_sidecar():
|
||||
items = [
|
||||
_reasoning_item(["plan a", " then b"], encrypted="blob"),
|
||||
_message_item("answer"),
|
||||
_call_item("call_1", "f", "{}"),
|
||||
]
|
||||
provider = OpenAIResponsesProvider(client=_FakeClient(response=_response(items)))
|
||||
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
|
||||
assert turn.reasoning == "plan a then b"
|
||||
assert turn.extras["_openai"]["items"] == items
|
||||
|
||||
|
||||
def test_complete_drops_unresolvable_reasoning_from_sidecar():
|
||||
# No encrypted_content (e.g. `include` got param-fix-dropped): replaying the item
|
||||
# under store:false would 400, so it must not enter the sidecar.
|
||||
items = [
|
||||
_reasoning_item(["hmm"], encrypted=None),
|
||||
_call_item("call_1", "f", "{}"),
|
||||
]
|
||||
provider = OpenAIResponsesProvider(client=_FakeClient(response=_response(items)))
|
||||
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
|
||||
assert turn.reasoning == "hmm" # still displayed…
|
||||
kinds = [i["type"] for i in turn.extras["_openai"]["items"]]
|
||||
assert kinds == ["function_call"] # …but never replayed
|
||||
|
||||
|
||||
def test_complete_plain_text_has_no_sidecar():
|
||||
provider = OpenAIResponsesProvider(
|
||||
client=_FakeClient(response=_response([_message_item("plain")]))
|
||||
)
|
||||
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
|
||||
assert turn.extras == {}
|
||||
|
||||
|
||||
def test_complete_maps_incomplete_max_tokens_to_length():
|
||||
provider = OpenAIResponsesProvider(
|
||||
client=_FakeClient(
|
||||
response=_response(
|
||||
[_message_item("truncat")],
|
||||
status="incomplete",
|
||||
incomplete_details=SimpleNamespace(reason="max_output_tokens"),
|
||||
)
|
||||
)
|
||||
)
|
||||
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
|
||||
assert turn.finish_reason == "length"
|
||||
|
||||
|
||||
def test_complete_filters_and_aliases_settings():
|
||||
fake = _FakeClient(response=_response([_message_item("x")]))
|
||||
provider = OpenAIResponsesProvider(client=fake)
|
||||
provider.complete(
|
||||
model="m",
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
temperature=0.2,
|
||||
max_tokens=512, # chat alias → max_output_tokens
|
||||
frequency_penalty=0.5, # not a Responses param → dropped
|
||||
reasoning_effort="high", # no effort knob in v1 → dropped
|
||||
)
|
||||
assert fake.kwargs["temperature"] == 0.2
|
||||
assert fake.kwargs["max_output_tokens"] == 512
|
||||
assert "max_tokens" not in fake.kwargs
|
||||
assert "frequency_penalty" not in fake.kwargs
|
||||
assert "reasoning_effort" not in fake.kwargs
|
||||
|
||||
|
||||
def test_complete_passes_flat_tools():
|
||||
fake = _FakeClient(response=_response([_message_item("x")]))
|
||||
provider = OpenAIResponsesProvider(client=fake)
|
||||
provider.complete(
|
||||
model="m",
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
tools=[{"type": "function", "function": {"name": "f"}}],
|
||||
)
|
||||
assert fake.kwargs["tools"] == [{"type": "function", "name": "f"}]
|
||||
|
||||
|
||||
def test_complete_parses_attr_style_sdk_objects():
|
||||
# The real SDK returns typed objects, not dicts — the parser must getattr its way in.
|
||||
response = SimpleNamespace(
|
||||
output=[
|
||||
SimpleNamespace(
|
||||
type="message",
|
||||
id="msg_1",
|
||||
role="assistant",
|
||||
content=[SimpleNamespace(type="output_text", text="hi", annotations=None)],
|
||||
),
|
||||
SimpleNamespace(
|
||||
type="function_call",
|
||||
id="fc_1",
|
||||
call_id="call_1",
|
||||
name="f",
|
||||
arguments='{"a": 1}',
|
||||
),
|
||||
],
|
||||
status="completed",
|
||||
incomplete_details=None,
|
||||
)
|
||||
provider = OpenAIResponsesProvider(client=_FakeClient(response=response))
|
||||
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
|
||||
assert turn.text == "hi"
|
||||
assert turn.tool_calls[0].id == "call_1"
|
||||
assert turn.tool_calls[0].arguments == {"a": 1}
|
||||
|
||||
|
||||
# -- param-fix retries ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_param_fix_drops_named_parameter():
|
||||
kwargs = {"model": "m", "input": [], "temperature": 0.2}
|
||||
fixed = _param_fix_retry(
|
||||
kwargs, Exception("Unsupported parameter: 'temperature' is not supported")
|
||||
)
|
||||
assert "temperature" not in fixed and kwargs["temperature"] == 0.2 # copy, not mutate
|
||||
|
||||
|
||||
def test_param_fix_dotted_name_drops_top_level():
|
||||
fixed = _param_fix_retry(
|
||||
{"model": "m", "input": [], "reasoning": {"summary": "auto"}},
|
||||
Exception("Unsupported parameter: 'reasoning.summary'"),
|
||||
)
|
||||
assert "reasoning" not in fixed
|
||||
|
||||
|
||||
def test_param_fix_reraises_unknown_errors():
|
||||
with pytest.raises(Exception, match="rate limit"):
|
||||
_param_fix_retry({"model": "m", "input": []}, Exception("rate limit exceeded"))
|
||||
|
||||
|
||||
def test_complete_retries_dropping_rejected_params():
|
||||
# A non-reasoning model rejecting `reasoning` then `include` — both retried away.
|
||||
fake = _FakeClient(
|
||||
response=_response([_message_item("ok")]),
|
||||
errors=[
|
||||
Exception("Unsupported parameter: 'reasoning'"),
|
||||
Exception("Unsupported value: 'include[0]'"),
|
||||
],
|
||||
)
|
||||
provider = OpenAIResponsesProvider(client=fake)
|
||||
turn = provider.complete(model="gpt-4.1", messages=[{"role": "user", "content": "x"}])
|
||||
assert turn.text == "ok"
|
||||
assert len(fake.calls) == 3
|
||||
assert "reasoning" not in fake.kwargs and "include" not in fake.kwargs
|
||||
|
||||
|
||||
# -- stream() ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stream_yields_deltas_then_final_turn_from_completed_event():
|
||||
final = _response(
|
||||
[
|
||||
_reasoning_item(["mull it over"], encrypted="blob"),
|
||||
_message_item("hello"),
|
||||
]
|
||||
)
|
||||
events = [
|
||||
SimpleNamespace(type="response.created"),
|
||||
SimpleNamespace(type="response.reasoning_summary_text.delta", delta="mull "),
|
||||
SimpleNamespace(type="response.reasoning_summary_text.delta", delta="it over"),
|
||||
SimpleNamespace(type="response.output_text.delta", delta="hel"),
|
||||
SimpleNamespace(type="response.output_text.delta", delta="lo"),
|
||||
SimpleNamespace(type="response.completed", response=final),
|
||||
]
|
||||
provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
|
||||
out = list(provider.stream(model="m", messages=[{"role": "user", "content": "x"}]))
|
||||
assert [c.reasoning_delta for c in out if c.reasoning_delta] == ["mull ", "it over"]
|
||||
assert [c.text_delta for c in out if c.text_delta] == ["hel", "lo"]
|
||||
turn = out[-1].turn
|
||||
assert turn.text == "hello" and turn.reasoning == "mull it over"
|
||||
assert turn.finish_reason == "stop"
|
||||
# Encrypted reasoning is replay-worthy even without function calls.
|
||||
assert [i["type"] for i in turn.extras["_openai"]["items"]] == [
|
||||
"reasoning",
|
||||
"message",
|
||||
]
|
||||
|
||||
|
||||
def test_stream_final_turn_carries_tool_calls_and_sidecar():
|
||||
final = _response(
|
||||
[
|
||||
_reasoning_item(["plan"], encrypted="blob"),
|
||||
_call_item("call_1", "f", '{"x": 1}'),
|
||||
]
|
||||
)
|
||||
events = [SimpleNamespace(type="response.completed", response=final)]
|
||||
provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
|
||||
turn = list(
|
||||
provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
|
||||
)[-1].turn
|
||||
assert turn.finish_reason == "tool_calls"
|
||||
assert turn.tool_calls[0].arguments == {"x": 1}
|
||||
assert [i["type"] for i in turn.extras["_openai"]["items"]] == [
|
||||
"reasoning",
|
||||
"function_call",
|
||||
]
|
||||
|
||||
|
||||
def test_stream_without_terminal_event_keeps_accumulated_text():
|
||||
events = [SimpleNamespace(type="response.output_text.delta", delta="partial")]
|
||||
provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
|
||||
turn = list(
|
||||
provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
|
||||
)[-1].turn
|
||||
assert turn.text == "partial" and turn.finish_reason is None
|
||||
|
||||
|
||||
def test_stream_requests_stream_flag():
|
||||
fake = _FakeClient(events=[])
|
||||
provider = OpenAIResponsesProvider(client=fake)
|
||||
list(provider.stream(model="m", messages=[{"role": "user", "content": "x"}]))
|
||||
assert fake.kwargs["stream"] is True
|
||||
|
||||
|
||||
# -- round trip ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sidecar_round_trip_replays_what_complete_stored():
|
||||
"""A tool loop: turn 1's sidecar items must be exactly what turn 2's request replays."""
|
||||
items = [
|
||||
_reasoning_item(["plan"], encrypted="blob"),
|
||||
_call_item("call_1", "f", "{}"),
|
||||
]
|
||||
provider = OpenAIResponsesProvider(client=_FakeClient(response=_response(items)))
|
||||
turn = provider.complete(model="m", messages=[{"role": "user", "content": "go"}])
|
||||
|
||||
# The engine persists canonical fields + extras (engine._assistant_message):
|
||||
assistant_message = {
|
||||
"role": "assistant",
|
||||
"content": turn.text or "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {"name": tc.name, "arguments": json.dumps(tc.arguments)},
|
||||
}
|
||||
for tc in turn.tool_calls
|
||||
],
|
||||
**turn.extras,
|
||||
}
|
||||
fake2 = _FakeClient(response=_response([_message_item("done")]))
|
||||
provider2 = OpenAIResponsesProvider(client=fake2)
|
||||
provider2.complete(
|
||||
model="m",
|
||||
messages=[
|
||||
{"role": "user", "content": "go"},
|
||||
assistant_message,
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "ok"},
|
||||
],
|
||||
)
|
||||
sent = fake2.kwargs["input"]
|
||||
assert sent[1:3] == items # replayed verbatim, reasoning first
|
||||
assert sent[3] == {
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": "ok",
|
||||
}
|
||||
|
||||
|
||||
def test_ensure_client_without_key_raises(monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
with pytest.raises(RuntimeError, match="No model API key"):
|
||||
OpenAIResponsesProvider()._ensure_client()
|
||||
|
||||
|
||||
# -- registry routing ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_routes_blank_endpoint_to_responses():
|
||||
from coworker.providers import OpenAIProvider
|
||||
from coworker.providers.registry import build_provider_client
|
||||
|
||||
assert isinstance(
|
||||
build_provider_client("openai", {}, None), OpenAIResponsesProvider
|
||||
)
|
||||
assert isinstance(
|
||||
build_provider_client("openai", {"base_url": " "}, None),
|
||||
OpenAIResponsesProvider,
|
||||
)
|
||||
# A custom endpoint (Azure, vLLM, any compat gateway) keeps Chat Completions…
|
||||
custom = build_provider_client(
|
||||
"openai", {"base_url": "https://my.azure.example/openai/v1"}, None
|
||||
)
|
||||
assert isinstance(custom, OpenAIProvider)
|
||||
# …and so do Ollama and every compat vendor (their own descriptors).
|
||||
assert isinstance(build_provider_client("ollama", {}, None), OpenAIProvider)
|
||||
assert isinstance(
|
||||
build_provider_client("deepseek", {"api_key": "sk-x"}, None), OpenAIProvider
|
||||
)
|
||||
@@ -409,12 +409,16 @@ def test_provider_builders(monkeypatch):
|
||||
with pytest.raises(RuntimeError, match="Gemini"):
|
||||
build_provider_client("gemini", {}, None)._ensure_client()
|
||||
|
||||
# OpenAI custom endpoint (Azure /openai/v1, OpenRouter, vLLM, …) passes through
|
||||
# OpenAI custom endpoint (Azure /openai/v1, OpenRouter, vLLM, …) passes through and
|
||||
# keeps Chat Completions; a blank endpoint means stock OpenAI → the Responses API.
|
||||
from coworker.providers import OpenAIResponsesProvider
|
||||
|
||||
o = build_provider_client(
|
||||
"openai", {"base_url": "https://my.azure.example/openai/v1"}, None
|
||||
)
|
||||
assert isinstance(o, OpenAIProvider)
|
||||
assert o._base_url == "https://my.azure.example/openai/v1"
|
||||
assert build_provider_client("openai", {}, None)._base_url is None
|
||||
assert isinstance(build_provider_client("openai", {}, None), OpenAIResponsesProvider)
|
||||
|
||||
|
||||
def test_anthropic_gemini_capabilities():
|
||||
|
||||
@@ -175,6 +175,33 @@ def test_artifacts_list_and_read_previewable_files(tmp_path):
|
||||
assert "<h1>Preview</h1>" in html["content"]
|
||||
|
||||
|
||||
def test_artifact_read_folder_returns_listing(tmp_path):
|
||||
"""A linked directory (e.g. a skill package dir) renders as a listing, never a dead
|
||||
'not found' (owner report 2026-07-27). Dirs first, then files, sizes on files only."""
|
||||
pkg = tmp_path / "directory-statistics"
|
||||
pkg.mkdir()
|
||||
(pkg / "SKILL.md").write_text("---\nname: x\n---\nbody", encoding="utf-8")
|
||||
(pkg / "stats.py").write_text("print(1)", encoding="utf-8")
|
||||
(pkg / "examples").mkdir()
|
||||
|
||||
client = _client(tmp_path, [])
|
||||
res = client.get(
|
||||
"/v1/sessions/unknown/artifacts/read", params={"path": "directory-statistics"}
|
||||
).json()
|
||||
assert res["ok"] is True and res["kind"] == "folder"
|
||||
names = [e["name"] for e in res["entries"]]
|
||||
assert names == ["examples", "SKILL.md", "stats.py"] # dirs first, then files by name
|
||||
assert res["entries"][0]["dir"] is True
|
||||
assert res["entries"][2]["size"] > 0
|
||||
|
||||
# A genuinely missing path keeps a friendly, non-jargon error.
|
||||
missing = client.get(
|
||||
"/v1/sessions/unknown/artifacts/read", params={"path": "nope.md"}
|
||||
).json()
|
||||
assert missing["ok"] is False
|
||||
assert "moved or deleted" in missing["error"]
|
||||
|
||||
|
||||
def test_artifact_read_rejects_path_escape(tmp_path):
|
||||
client = _client(tmp_path, [])
|
||||
escaped = client.get(
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
"""SKILLS-SPEC §4.6 — REST endpoints, session mutes over HTTP, WS force-run framing.
|
||||
|
||||
Follows the codebase's API convention: validation failures return ``{"ok": False,
|
||||
"error": …}`` bodies (not raw 4xx), matching every other /v1 management endpoint.
|
||||
Engine integration runs on ScriptedProvider — no LLM, no network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from coworker.providers import AssistantTurn, ModelCapabilities, ProviderClient
|
||||
from coworker.server import SessionManager, create_app
|
||||
|
||||
|
||||
class ScriptedProvider(ProviderClient):
|
||||
"""Queued turns + captured `messages` so tests can assert what the model saw."""
|
||||
|
||||
def __init__(self, turns=None):
|
||||
self._turns = list(turns or [])
|
||||
self.seen: list[list[dict]] = []
|
||||
|
||||
def complete(self, *, model, messages, tools=None, **settings):
|
||||
self.seen.append(messages)
|
||||
return self._turns.pop(0)
|
||||
|
||||
def capabilities(self, model):
|
||||
return ModelCapabilities()
|
||||
|
||||
|
||||
def _client(tmp_path, turns=None):
|
||||
provider = ScriptedProvider(turns)
|
||||
manager = SessionManager(workspace=tmp_path, provider=provider)
|
||||
return TestClient(create_app(manager)), manager, provider
|
||||
|
||||
|
||||
def _zip_b64(entries: dict[str, str]) -> str:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
for name, content in entries.items():
|
||||
zf.writestr(name, content)
|
||||
return base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
GREET = {
|
||||
"name": "greet",
|
||||
"description": "says hello",
|
||||
"instructions": "Say hello warmly.",
|
||||
}
|
||||
|
||||
|
||||
# -- CRUD -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_then_list_enriched(tmp_path):
|
||||
client, _m, _p = _client(tmp_path)
|
||||
assert client.post("/v1/skills", json=GREET).json()["ok"] is True
|
||||
rows = client.get("/v1/skills").json()["skills"]
|
||||
assert rows == [
|
||||
{
|
||||
"name": "greet",
|
||||
"description": "says hello",
|
||||
"instructions": "Say hello warmly.",
|
||||
"scope": "global",
|
||||
"source": "local",
|
||||
"enabled": True,
|
||||
"path": rows[0]["path"],
|
||||
"files": 0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_create_duplicate_and_blank_rejected(tmp_path):
|
||||
client, _m, _p = _client(tmp_path)
|
||||
client.post("/v1/skills", json=GREET)
|
||||
dup = client.post("/v1/skills", json=GREET).json()
|
||||
assert dup["ok"] is False and "already exists" in dup["error"]
|
||||
for bad in (
|
||||
{},
|
||||
{"name": "", "instructions": "x"},
|
||||
{"name": "ok-name", "instructions": " "},
|
||||
):
|
||||
res = client.post("/v1/skills", json=bad).json()
|
||||
assert res["ok"] is False and res["error"]
|
||||
|
||||
|
||||
def test_patch_edit_and_toggle(tmp_path):
|
||||
client, _m, _p = _client(tmp_path)
|
||||
client.post("/v1/skills", json=GREET)
|
||||
assert (
|
||||
client.patch(
|
||||
"/v1/skills/greet", json={"description": "hi", "enabled": False}
|
||||
).json()["ok"]
|
||||
is True
|
||||
)
|
||||
row = client.get("/v1/skills").json()["skills"][0]
|
||||
assert row["description"] == "hi" and row["enabled"] is False
|
||||
unknown = client.patch("/v1/skills/ghost", json={"description": "x"}).json()
|
||||
assert unknown["ok"] is False
|
||||
|
||||
|
||||
def test_delete_and_unknown(tmp_path):
|
||||
client, _m, _p = _client(tmp_path)
|
||||
client.post("/v1/skills", json=GREET)
|
||||
assert client.delete("/v1/skills/greet").json()["ok"] is True
|
||||
assert client.get("/v1/skills").json()["skills"] == []
|
||||
assert client.delete("/v1/skills/greet").json()["ok"] is False
|
||||
|
||||
|
||||
def test_move_happy_and_collision(tmp_path):
|
||||
client, _m, _p = _client(tmp_path)
|
||||
ws = tmp_path / "proj"
|
||||
ws.mkdir()
|
||||
client.post("/v1/skills", json=GREET)
|
||||
moved = client.post(
|
||||
"/v1/skills/greet/move", json={"scope": "project", "workspace": str(ws)}
|
||||
).json()
|
||||
assert moved["ok"] is True and moved["skill"]["scope"] == "project"
|
||||
client.post("/v1/skills", json=GREET) # recreate global → collision on move back
|
||||
res = client.post(
|
||||
"/v1/skills/greet/move", json={"scope": "global", "workspace": str(ws)}
|
||||
).json()
|
||||
assert res["ok"] is False and "already exists" in res["error"]
|
||||
|
||||
|
||||
def test_create_project_scope_requires_real_workspace(tmp_path):
|
||||
client, _m, _p = _client(tmp_path)
|
||||
res = client.post(
|
||||
"/v1/skills",
|
||||
json={**GREET, "scope": "project", "workspace": str(tmp_path / "nope")},
|
||||
).json()
|
||||
assert res["ok"] is False and "workspace" in res["error"].lower()
|
||||
|
||||
|
||||
def test_scratch_workspace_rejected_for_skill_writes(tmp_path):
|
||||
"""A per-conversation scratch dir is not a project: create/move-into/confirm all refuse
|
||||
it at the manager chokepoint; moving OUT of one still works (the rescue path)."""
|
||||
client, manager, _p = _client(tmp_path)
|
||||
scratch_base = tmp_path / "scratchpads"
|
||||
manager.set_scratch_base(str(scratch_base))
|
||||
scratch_ws = scratch_base / "6d57038c-50d"
|
||||
(scratch_ws / ".coworker" / "skills").mkdir(parents=True)
|
||||
|
||||
res = client.post(
|
||||
"/v1/skills",
|
||||
json={**GREET, "scope": "project", "workspace": str(scratch_ws)},
|
||||
).json()
|
||||
assert res["ok"] is False and "temporary" in res["error"].lower()
|
||||
|
||||
client.post("/v1/skills", json=GREET) # global
|
||||
res = client.post(
|
||||
"/v1/skills/greet/move", json={"scope": "project", "workspace": str(scratch_ws)}
|
||||
).json()
|
||||
assert res["ok"] is False and "temporary" in res["error"].lower()
|
||||
|
||||
md = "---\nname: zipped\ndescription: d\n---\nbody\n"
|
||||
preview = client.post(
|
||||
"/v1/skills/upload", json={"data_b64": _zip_b64({"zipped/SKILL.md": md})}
|
||||
).json()
|
||||
res = client.post(
|
||||
"/v1/skills/upload/confirm",
|
||||
json={"token": preview["token"], "scope": "project", "workspace": str(scratch_ws)},
|
||||
).json()
|
||||
assert res["ok"] is False and "temporary" in res["error"].lower()
|
||||
|
||||
# Rescue path: a skill already stranded in scratch can still move OUT to global.
|
||||
manager.skill_store.create(
|
||||
name="stranded", description="", instructions="x",
|
||||
scope="project", workspace=scratch_ws,
|
||||
)
|
||||
res = client.post(
|
||||
"/v1/skills/stranded/move",
|
||||
json={"scope": "global", "workspace": str(scratch_ws)},
|
||||
).json()
|
||||
assert res["ok"] is True and res["skill"]["scope"] == "global"
|
||||
|
||||
|
||||
def test_path_traversal_names_rejected(tmp_path):
|
||||
client, _m, _p = _client(tmp_path)
|
||||
# ".." must be encoded — the HTTP client itself normalizes a literal /.. away.
|
||||
assert client.delete("/v1/skills/%2e%2e").json()["ok"] is False
|
||||
res = client.post("/v1/skills", json={**GREET, "name": "..%2Fevil"}).json()
|
||||
assert res["ok"] is False
|
||||
|
||||
|
||||
# -- upload + draft -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_preview_then_confirm(tmp_path):
|
||||
client, _m, _p = _client(tmp_path)
|
||||
md = "---\nname: greet\ndescription: says hello\n---\nSay hello.\n"
|
||||
preview = client.post(
|
||||
"/v1/skills/upload", json={"data_b64": _zip_b64({"greet/SKILL.md": md})}
|
||||
).json()
|
||||
assert preview["ok"] is True and preview["name"] == "greet"
|
||||
assert client.get("/v1/skills").json()["skills"] == [] # preview installs nothing
|
||||
confirmed = client.post(
|
||||
"/v1/skills/upload/confirm", json={"token": preview["token"]}
|
||||
).json()
|
||||
assert confirmed["ok"] is True
|
||||
row = client.get("/v1/skills").json()["skills"][0]
|
||||
assert row["source"] == "uploaded"
|
||||
|
||||
|
||||
def test_upload_invalid_archive_friendly(tmp_path):
|
||||
client, _m, _p = _client(tmp_path)
|
||||
bad = client.post(
|
||||
"/v1/skills/upload",
|
||||
json={"data_b64": base64.b64encode(b"not a zip").decode(), "filename": "x.zip"},
|
||||
).json()
|
||||
assert bad["ok"] is False and "zip" in bad["error"].lower()
|
||||
# A bare .md without frontmatter gets the md-specific guidance.
|
||||
bare = client.post(
|
||||
"/v1/skills/upload",
|
||||
json={"data_b64": base64.b64encode(b"no frontmatter").decode(), "filename": "a.md"},
|
||||
).json()
|
||||
assert bare["ok"] is False and "frontmatter" in bare["error"].lower()
|
||||
assert client.post("/v1/skills/upload", json={}).json()["ok"] is False
|
||||
assert (
|
||||
client.post("/v1/skills/upload", json={"data_b64": "!!!"}).json()["ok"] is False
|
||||
)
|
||||
|
||||
|
||||
def test_draft_endpoint_is_gone(tmp_path):
|
||||
"""The drafting path retired with the worker-authors flow (SKILLS-SPEC §5.2/§9):
|
||||
creation is a conversation ending in save_skill, not a Settings endpoint."""
|
||||
client, _m, _p = _client(tmp_path)
|
||||
# 405 not 404: the path now falls through to PATCH /v1/skills/{name}. Either way,
|
||||
# POSTing a draft is no longer a thing.
|
||||
assert client.post("/v1/skills/draft", json={"description": "x"}).status_code in (404, 405)
|
||||
|
||||
|
||||
# -- session mutes over HTTP ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_session_mute_roundtrip(tmp_path):
|
||||
client, _m, _p = _client(tmp_path)
|
||||
client.post("/v1/skills", json=GREET)
|
||||
view = client.get("/v1/sessions/s1/skills").json()["skills"]
|
||||
assert view == [
|
||||
{"name": "greet", "description": "says hello", "scope": "global", "enabled": True}
|
||||
]
|
||||
after = client.post(
|
||||
"/v1/sessions/s1/skills", json={"skill": "greet", "enabled": False}
|
||||
).json()["skills"]
|
||||
assert after[0]["enabled"] is False
|
||||
other = client.get("/v1/sessions/s2/skills").json()["skills"]
|
||||
assert other[0]["enabled"] is True # mute is per-session
|
||||
cleared = client.post(
|
||||
"/v1/sessions/s1/skills", json={"skill": "greet", "clear": True}
|
||||
).json()["skills"]
|
||||
assert cleared[0]["enabled"] is True
|
||||
assert (
|
||||
client.post("/v1/sessions/s1/skills", json={}).json()["ok"] is False
|
||||
) # null input
|
||||
|
||||
|
||||
# -- engine integration (ScriptedProvider, no LLM) --------------------------------------
|
||||
|
||||
|
||||
def test_engine_catalog_respects_settings_disable(tmp_path):
|
||||
client, manager, _p = _client(tmp_path)
|
||||
client.post("/v1/skills", json=GREET)
|
||||
client.post(
|
||||
"/v1/skills", json={"name": "hidden", "description": "off", "instructions": "x"}
|
||||
)
|
||||
client.patch("/v1/skills/hidden", json={"enabled": False})
|
||||
|
||||
from coworker.agent import build_engine
|
||||
from coworker.agents.registry import get_agent
|
||||
|
||||
engine = build_engine(
|
||||
agent=get_agent("chat"),
|
||||
provider=ScriptedProvider(),
|
||||
skill_filter=lambda: manager.effective_skill_names("s1"),
|
||||
)
|
||||
# The menu rides the live per-turn context block (§4.1), not the system prompt.
|
||||
menu = engine.context_provider()
|
||||
assert "greet" in menu
|
||||
assert "hidden" not in menu
|
||||
assert "greet" not in engine.messages[0]["content"]
|
||||
|
||||
|
||||
def _drain(ws):
|
||||
types = []
|
||||
while True:
|
||||
evt = ws.receive_json()
|
||||
types.append(evt["type"])
|
||||
if evt["type"] in {"turn_done", "input_rejected"}:
|
||||
return types, evt
|
||||
|
||||
|
||||
def test_ws_force_run_frames_the_turn(tmp_path):
|
||||
"""The display/model split (§4.1 #3): the provider gets the framing; the transcript
|
||||
(TURN_START + the persisted message's `_display`) gets the user's literal '/name …'."""
|
||||
client, _m, provider = _client(tmp_path, [AssistantTurn(text="done")])
|
||||
client.post("/v1/skills", json=GREET)
|
||||
with client.websocket_connect("/ws/session/s1?agent=chat") as ws:
|
||||
assert ws.receive_json()["type"] == "ready"
|
||||
ws.send_json({"type": "user_message", "text": "hello", "skill": "greet"})
|
||||
events = []
|
||||
while True:
|
||||
evt = ws.receive_json()
|
||||
events.append(evt)
|
||||
if evt["type"] == "turn_done":
|
||||
break
|
||||
framed = provider.seen[-1][-1]["content"]
|
||||
assert 'load_skill("greet")' in str(framed)
|
||||
assert "hello" in str(framed)
|
||||
start = next(e for e in events if e["type"] == "turn_start")
|
||||
assert start["data"]["display"] == "/greet hello" # what the transcript shows
|
||||
assert "load_skill" in str(start["data"]["input"]) # what the model saw
|
||||
stored = client.get("/v1/sessions/s1/messages").json()["messages"]
|
||||
user = next(m for m in stored if m["role"] == "user")
|
||||
assert user["_display"] == "/greet hello"
|
||||
assert "load_skill" in str(user["content"])
|
||||
|
||||
|
||||
def test_ws_force_run_unknown_and_muted_error_without_killing_socket(tmp_path):
|
||||
client, _m, provider = _client(tmp_path, [AssistantTurn(text="ok")])
|
||||
client.post("/v1/skills", json=GREET)
|
||||
client.post("/v1/sessions/s1/skills", json={"skill": "greet", "enabled": False})
|
||||
with client.websocket_connect("/ws/session/s1?agent=chat") as ws:
|
||||
assert ws.receive_json()["type"] == "ready"
|
||||
# unknown skill → visible rejection, no turn
|
||||
ws.send_json({"type": "user_message", "text": "x", "skill": "ghost"})
|
||||
evt = ws.receive_json()
|
||||
assert evt["type"] == "input_rejected"
|
||||
assert "not available" in evt["data"]["error"]
|
||||
# muted skill → same rejection (§4.6 #15: no silent auto-unmute)
|
||||
ws.send_json({"type": "user_message", "text": "x", "skill": "greet"})
|
||||
evt = ws.receive_json()
|
||||
assert evt["type"] == "input_rejected"
|
||||
# empty name → invalid frame
|
||||
ws.send_json({"type": "user_message", "text": "x", "skill": " "})
|
||||
assert ws.receive_json()["type"] == "input_rejected"
|
||||
# socket still healthy: a normal message runs a turn
|
||||
ws.send_json({"type": "user_message", "text": "plain"})
|
||||
types, _ = _drain(ws)
|
||||
assert "turn_done" in types
|
||||
# No force-run framing ever reached the model (the catalog line in the system prompt
|
||||
# legitimately mentions load_skill — the invariant is about USER messages only).
|
||||
users = [
|
||||
str(m.get("content"))
|
||||
for msgs in provider.seen
|
||||
for m in msgs
|
||||
if m.get("role") == "user"
|
||||
]
|
||||
assert all("Use the skill" not in u for u in users)
|
||||
assert any("plain" in u for u in users)
|
||||
|
||||
|
||||
def test_reveal_unknown_skill_is_a_friendly_error(tmp_path):
|
||||
client, _m, _p = _client(tmp_path)
|
||||
res = client.post("/v1/skills/nope/reveal", json={}).json()
|
||||
assert res["ok"] is False and "nope" in res["error"]
|
||||
@@ -0,0 +1,265 @@
|
||||
"""SKILLS-SPEC §4.6 — the effective menu: merged scopes − Settings disables − session mutes.
|
||||
|
||||
One resolver (`effective_skills` / manager.effective_skill_names) feeds the engine catalog,
|
||||
the rail list, and the composer popup — the parity test pins that they can never disagree.
|
||||
Any-off-wins: a Settings disable can NOT be resurrected by a session override.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from coworker.providers import ModelCapabilities, ProviderClient
|
||||
from coworker.skills import (
|
||||
SessionSkillStore,
|
||||
SkillLoader,
|
||||
SkillStore,
|
||||
effective_skills,
|
||||
skill_catalog_text,
|
||||
skill_tools,
|
||||
)
|
||||
from coworker.server.manager import SessionManager
|
||||
|
||||
|
||||
class ScriptedProvider(ProviderClient):
|
||||
def __init__(self, turns=None):
|
||||
self._turns = list(turns or [])
|
||||
|
||||
def complete(self, *, model, messages, tools=None, **settings):
|
||||
return self._turns.pop(0)
|
||||
|
||||
def capabilities(self, model):
|
||||
return ModelCapabilities()
|
||||
|
||||
|
||||
def _skill(base: Path, name: str, description: str = "", body: str = "do it") -> None:
|
||||
d = base / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: {description}\n---\n\n{body}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def manager(tmp_path):
|
||||
return SessionManager(workspace=tmp_path / "ws", provider=ScriptedProvider())
|
||||
|
||||
|
||||
# -- resolver ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_project_copy_wins_merge(tmp_path):
|
||||
_skill(tmp_path / "g", "report", body="generic steps")
|
||||
_skill(tmp_path / "p", "report", body="project steps")
|
||||
loader = SkillLoader([tmp_path / "g", tmp_path / "p"]) # global first, local last
|
||||
assert loader.get("report").instructions == "project steps"
|
||||
|
||||
|
||||
def test_disabled_absent_everywhere():
|
||||
assert effective_skills(
|
||||
names={"a", "b"}, disabled={"a"}, session_overrides={}
|
||||
) == {"b"}
|
||||
|
||||
|
||||
def test_mute_hides_in_that_session_only(tmp_path):
|
||||
store = SessionSkillStore(tmp_path / "s.json")
|
||||
store.set("s1", "a", False)
|
||||
names = {"a", "b"}
|
||||
s1 = effective_skills(names=names, disabled=set(), session_overrides=store.get("s1"))
|
||||
s2 = effective_skills(names=names, disabled=set(), session_overrides=store.get("s2"))
|
||||
assert s1 == {"b"} and s2 == {"a", "b"}
|
||||
|
||||
|
||||
def test_removed_session_inherits_clean(tmp_path):
|
||||
store = SessionSkillStore(tmp_path / "s.json")
|
||||
store.set("s1", "a", False)
|
||||
store.remove_session("s1")
|
||||
assert store.get("s1") == {}
|
||||
|
||||
|
||||
def test_mute_of_unknown_skill_is_noop():
|
||||
out = effective_skills(
|
||||
names={"real"}, disabled=set(), session_overrides={"ghost": False}
|
||||
)
|
||||
assert out == {"real"}
|
||||
|
||||
|
||||
def test_any_off_wins_both_directions():
|
||||
# enabled in Settings + muted in session → out
|
||||
assert effective_skills(
|
||||
names={"a"}, disabled=set(), session_overrides={"a": False}
|
||||
) == set()
|
||||
# disabled in Settings + explicit session-on → STILL out (no resurrection)
|
||||
assert effective_skills(
|
||||
names={"a"}, disabled={"a"}, session_overrides={"a": True}
|
||||
) == set()
|
||||
|
||||
|
||||
def test_override_store_survives_reload(tmp_path):
|
||||
SessionSkillStore(tmp_path / "s.json").set("s1", "a", False)
|
||||
assert SessionSkillStore(tmp_path / "s.json").get("s1") == {"a": False}
|
||||
|
||||
|
||||
def test_concurrent_sessions_same_workspace_independent(tmp_path):
|
||||
store = SessionSkillStore(tmp_path / "s.json")
|
||||
store.set("s1", "a", False)
|
||||
store.set("s2", "b", False)
|
||||
assert store.get("s1") == {"a": False}
|
||||
assert store.get("s2") == {"b": False}
|
||||
|
||||
|
||||
# -- manager resolution ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_workspace_means_global_only(manager, tmp_path):
|
||||
_skill(manager.skill_store.global_dir, "everywhere")
|
||||
ws = tmp_path / "elsewhere"
|
||||
(ws / ".coworker" / "skills").mkdir(parents=True)
|
||||
_skill(ws / ".coworker" / "skills", "local-only")
|
||||
assert manager.effective_skill_names("s1") == {"everywhere"}
|
||||
assert manager.effective_skill_names("s1", ws) == {"everywhere", "local-only"}
|
||||
|
||||
|
||||
def test_workspace_without_skills_dir_is_fine(manager, tmp_path):
|
||||
ws = tmp_path / "bare-ws"
|
||||
ws.mkdir()
|
||||
assert manager.effective_skill_names("s1", ws) == set()
|
||||
|
||||
|
||||
def test_empty_catalog_is_safe(tmp_path):
|
||||
from coworker.tools.registry import ToolRegistry
|
||||
|
||||
loader = SkillLoader([tmp_path / "nowhere"])
|
||||
assert skill_catalog_text(loader) == ""
|
||||
reg = ToolRegistry()
|
||||
reg.register_all(skill_tools(loader))
|
||||
result = reg.execute("load_skill", {"name": "ghost"})
|
||||
assert result["error"].startswith("unknown skill")
|
||||
assert result["available"] == []
|
||||
|
||||
|
||||
def test_live_load_skill_semantics(manager):
|
||||
"""SKILLS-SPEC state table — EVERYTHING the model sees is live per turn:
|
||||
· the menu (context_provider) reflects installs/disables from the NEXT MESSAGE —
|
||||
no new session needed (mid-session import UX, decided 2026-07-27);
|
||||
· load_skill consults live state per call (create-after-build loadable; a Settings
|
||||
disable applies to RUNNING sessions; delete ≡ disable to the model);
|
||||
· the ONLY thing that persists is what a conversation already loaded (history)."""
|
||||
from coworker.agent import build_engine
|
||||
from coworker.agents.registry import get_agent
|
||||
|
||||
_skill(manager.skill_store.global_dir, "early", body="early body")
|
||||
engine = build_engine(
|
||||
agent=get_agent("chat"),
|
||||
provider=ScriptedProvider(),
|
||||
skill_filter=lambda: manager.effective_skill_names("s1"),
|
||||
)
|
||||
# The menu lives in the per-turn context block, not the static system prompt.
|
||||
assert "early" in engine.context_provider()
|
||||
assert "Available skills" not in engine.messages[0]["content"]
|
||||
|
||||
# created after build → in the menu from the next turn AND loadable
|
||||
manager.create_skill(
|
||||
{"name": "late", "description": "d", "instructions": "late body"}
|
||||
)
|
||||
assert "late" in engine.context_provider()
|
||||
loaded = engine.registry.execute("load_skill", {"name": "late"})
|
||||
assert loaded["instructions"] == "late body"
|
||||
|
||||
# disable → gone from the menu next turn, refused on load, listed nowhere
|
||||
manager.skill_store.set_enabled("early", False)
|
||||
assert "early" not in engine.context_provider()
|
||||
refused = engine.registry.execute("load_skill", {"name": "early"})
|
||||
assert refused["error"].startswith("unknown skill")
|
||||
assert "early" not in refused["available"]
|
||||
|
||||
# delete ≡ disable, from the model's side
|
||||
manager.delete_skill("late")
|
||||
assert "late" not in engine.context_provider()
|
||||
gone = engine.registry.execute("load_skill", {"name": "late"})
|
||||
assert gone["error"].startswith("unknown skill")
|
||||
|
||||
# re-enable → back next turn, files untouched all along (OFF is parking, not deletion)
|
||||
manager.skill_store.set_enabled("early", True)
|
||||
assert "early" in engine.context_provider()
|
||||
assert (
|
||||
engine.registry.execute("load_skill", {"name": "early"})["instructions"]
|
||||
== "early body"
|
||||
)
|
||||
|
||||
|
||||
def test_disable_countermand_for_loaded_skills(manager):
|
||||
"""§3: a skill whose instructions already entered the conversation gets an explicit
|
||||
per-turn stop note once disabled/deleted — menus shrinking is passive, instructions in
|
||||
history are not. Recomputed fresh: re-enabling clears it; unloaded skills never get one."""
|
||||
import json as _json
|
||||
|
||||
from coworker.agent import build_engine
|
||||
from coworker.agents.registry import get_agent
|
||||
|
||||
_skill(manager.skill_store.global_dir, "used-one", body="used body")
|
||||
_skill(manager.skill_store.global_dir, "unused-one", body="never loaded")
|
||||
engine = build_engine(
|
||||
agent=get_agent("chat"),
|
||||
provider=ScriptedProvider(),
|
||||
skill_filter=lambda: manager.effective_skill_names("s1"),
|
||||
)
|
||||
# Simulate a successful load earlier in this conversation (OpenAI message shape).
|
||||
engine.messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "load_skill",
|
||||
"arguments": _json.dumps({"name": "used-one"}),
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
engine.messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1",
|
||||
"content": _json.dumps(
|
||||
{"name": "used-one", "instructions": "used body", "resources_path": "x"}
|
||||
),
|
||||
}
|
||||
)
|
||||
assert "disabled by the user" not in engine.context_provider()
|
||||
|
||||
manager.skill_store.set_enabled("used-one", False)
|
||||
ctx = engine.context_provider()
|
||||
assert 'skill "used-one" has been disabled' in ctx
|
||||
assert "- used-one:" not in ctx # gone from the menu itself
|
||||
assert "- unused-one:" in ctx # untouched skill still offered, no note for it
|
||||
|
||||
manager.skill_store.set_enabled("used-one", True)
|
||||
assert "disabled by the user" not in engine.context_provider() # self-healing
|
||||
|
||||
manager.delete_skill("used-one") # delete ≡ disable for the countermand too
|
||||
assert 'skill "used-one" has been disabled' in engine.context_provider()
|
||||
|
||||
|
||||
def test_parity_catalog_vs_rail_view(manager):
|
||||
"""The §3 invariant: the engine's menu and the rail payload come from one resolver."""
|
||||
_skill(manager.skill_store.global_dir, "alpha")
|
||||
_skill(manager.skill_store.global_dir, "beta")
|
||||
_skill(manager.skill_store.global_dir, "gamma")
|
||||
manager.skill_store.set_enabled("beta", False) # Settings disable → gone from BOTH
|
||||
manager.session_skills.set("s1", "gamma", False) # session mute → rail row off
|
||||
|
||||
menu = manager.effective_skill_names("s1")
|
||||
view = manager.session_skills_view("s1")["skills"]
|
||||
view_names = {r["name"] for r in view}
|
||||
view_on = {r["name"] for r in view if r["enabled"]}
|
||||
|
||||
assert menu == {"alpha"}
|
||||
assert view_names == {"alpha", "gamma"} # disabled hidden; muted still listed (toggle)
|
||||
assert view_on == menu # what's ON in the rail == what the model sees
|
||||
@@ -0,0 +1,412 @@
|
||||
"""SKILLS-SPEC §4.6 — SkillStore: folder-backed CRUD, parsing edges, staged uploads.
|
||||
|
||||
Scope = folder location (folder-is-truth). These tests pin the store's safety rails:
|
||||
skill names become folder names (traversal guards), uploads are staged and previewed
|
||||
before anything lands in a scope dir, and disable state is personal (settings JSON,
|
||||
never a marker committed with a project folder).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from coworker.skills import SkillLoader, SkillStore, validate_name
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def store(tmp_path):
|
||||
return SkillStore(global_dir=tmp_path / "global-skills")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def workspace(tmp_path):
|
||||
ws = tmp_path / "proj"
|
||||
ws.mkdir()
|
||||
return ws
|
||||
|
||||
|
||||
def _zip_bytes(entries: dict[str, str]) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
for name, content in entries.items():
|
||||
zf.writestr(name, content)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
SKILL_MD = "---\nname: greet\ndescription: says hello\n---\n\nSay hello warmly.\n"
|
||||
|
||||
|
||||
# -- create ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_global_roundtrip(store):
|
||||
created = store.create(
|
||||
name="weekly-report",
|
||||
description="Monday status report",
|
||||
instructions="1. Gather updates\n2. Write the report",
|
||||
)
|
||||
assert created["scope"] == "global"
|
||||
loader = SkillLoader([store.global_dir])
|
||||
skill = loader.get("weekly-report")
|
||||
assert skill.description == "Monday status report"
|
||||
assert "Gather updates" in skill.instructions
|
||||
|
||||
|
||||
def test_create_project_scoped(store, workspace):
|
||||
store.create(
|
||||
name="release-checklist",
|
||||
description="repo release steps",
|
||||
instructions="Run the checklist.",
|
||||
scope="project",
|
||||
workspace=workspace,
|
||||
)
|
||||
md = workspace / ".coworker" / "skills" / "release-checklist" / "SKILL.md"
|
||||
assert md.is_file()
|
||||
|
||||
|
||||
def test_create_duplicate_rejected(store):
|
||||
store.create(name="dup", description="", instructions="x")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
store.create(name="dup", description="", instructions="y")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
["", " ", "a" * 65, "../evil", "a/b", "a\\b", ".hidden", "café"],
|
||||
)
|
||||
def test_invalid_names_rejected(bad):
|
||||
with pytest.raises(ValueError):
|
||||
validate_name(bad)
|
||||
|
||||
|
||||
def test_blank_instructions_rejected(store):
|
||||
with pytest.raises(ValueError, match="instructions"):
|
||||
store.create(name="empty", description="d", instructions=" ")
|
||||
|
||||
|
||||
# -- update / delete / move --------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_preserves_resources(store):
|
||||
store.create(name="tpl", description="v1", instructions="old body")
|
||||
extra = store.global_dir / "tpl" / "template.txt"
|
||||
extra.write_text("keep me", encoding="utf-8")
|
||||
store.update("tpl", instructions="new body")
|
||||
loader = SkillLoader([store.global_dir])
|
||||
assert loader.get("tpl").instructions == "new body"
|
||||
assert loader.get("tpl").description == "v1" # untouched field survives
|
||||
assert extra.read_text(encoding="utf-8") == "keep me"
|
||||
|
||||
|
||||
def test_delete_and_unknown(store):
|
||||
store.create(name="gone", description="", instructions="x")
|
||||
store.delete("gone")
|
||||
assert not (store.global_dir / "gone").exists()
|
||||
with pytest.raises(ValueError, match="Unknown skill"):
|
||||
store.delete("gone")
|
||||
|
||||
|
||||
def test_delete_symlinked_folder_not_followed(store, tmp_path):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "SKILL.md").write_text(SKILL_MD, encoding="utf-8")
|
||||
store.global_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.symlink(outside, store.global_dir / "greet", target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks unavailable on this platform/user")
|
||||
# Either refused (escape guard) or unlinked in place — the target must survive.
|
||||
try:
|
||||
store.delete("greet")
|
||||
except ValueError:
|
||||
pass
|
||||
assert (outside / "SKILL.md").is_file()
|
||||
|
||||
|
||||
def test_move_roundtrip(store, workspace):
|
||||
store.create(name="mover", description="", instructions="x")
|
||||
moved = store.move("mover", to_scope="project", workspace=workspace)
|
||||
assert moved["scope"] == "project"
|
||||
assert (workspace / ".coworker" / "skills" / "mover" / "SKILL.md").is_file()
|
||||
assert not (store.global_dir / "mover").exists()
|
||||
store.move("mover", to_scope="global", workspace=workspace)
|
||||
assert (store.global_dir / "mover" / "SKILL.md").is_file()
|
||||
|
||||
|
||||
def test_move_collision_leaves_source(store, workspace):
|
||||
store.create(name="both", description="global copy", instructions="g")
|
||||
store.create(
|
||||
name="both",
|
||||
description="project copy",
|
||||
instructions="p",
|
||||
scope="project",
|
||||
workspace=workspace,
|
||||
)
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
store.move("both", to_scope="global", workspace=workspace)
|
||||
# most-local find() → the project copy was the move source and it survives
|
||||
assert (workspace / ".coworker" / "skills" / "both" / "SKILL.md").is_file()
|
||||
|
||||
|
||||
# -- parsing edges (null/malformed input never crashes) -----------------------------
|
||||
|
||||
|
||||
def _manual_skill(base: Path, folder: str, text: str) -> None:
|
||||
d = base / folder
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def test_no_frontmatter_falls_back_to_folder_name(store):
|
||||
_manual_skill(store.global_dir, "bare", "Just instructions, no frontmatter.")
|
||||
rows = store.rows()
|
||||
assert rows[0]["name"] == "bare"
|
||||
assert rows[0]["description"] == ""
|
||||
|
||||
|
||||
def test_unterminated_frontmatter_no_crash(store):
|
||||
_manual_skill(store.global_dir, "broken", "---\nname: broken\nno closing fence")
|
||||
rows = store.rows()
|
||||
assert rows[0]["name"] == "broken"
|
||||
|
||||
|
||||
def test_empty_skill_md_no_crash(store):
|
||||
_manual_skill(store.global_dir, "hollow", "")
|
||||
rows = store.rows()
|
||||
assert rows[0]["name"] == "hollow"
|
||||
assert rows[0]["enabled"] is True
|
||||
|
||||
|
||||
def test_unicode_content_and_crlf_roundtrip(store):
|
||||
_manual_skill(
|
||||
store.global_dir,
|
||||
"emoji",
|
||||
"---\r\nname: emoji\r\ndescription: says 你好 🎉\r\n---\r\n\r\nGreet with 🎉.\r\n",
|
||||
)
|
||||
loader = SkillLoader([store.global_dir])
|
||||
skill = loader.get("emoji")
|
||||
assert "🎉" in skill.description
|
||||
assert "你好" in skill.description
|
||||
|
||||
|
||||
def test_frontmatter_name_wins_and_keys_collisions(store, workspace):
|
||||
store.create(name="brand", description="global copy", instructions="g")
|
||||
_manual_skill(
|
||||
workspace / ".coworker" / "skills",
|
||||
"other-folder",
|
||||
"---\nname: brand\ndescription: project copy\n---\nbody",
|
||||
)
|
||||
rows = store.rows(workspace)
|
||||
brand = [r for r in rows if r["name"] == "brand"]
|
||||
assert len(brand) == 1 # one row per name, not per folder
|
||||
assert brand[0]["scope"] == "project" # project copy shadows global
|
||||
|
||||
|
||||
# -- uploads -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_zip_at_root_and_nested(store):
|
||||
for entries in (
|
||||
{"SKILL.md": SKILL_MD},
|
||||
{"greet/SKILL.md": SKILL_MD, "greet/notes.txt": "extra"},
|
||||
):
|
||||
preview = store.stage_upload(_zip_bytes(entries))
|
||||
assert preview["name"] == "greet"
|
||||
assert preview["description"] == "says hello"
|
||||
store.discard_upload(preview["token"])
|
||||
|
||||
|
||||
def test_upload_without_skill_md_rejected(store):
|
||||
with pytest.raises(ValueError, match="SKILL.md"):
|
||||
store.stage_upload(_zip_bytes({"readme.txt": "not a skill"}))
|
||||
# A broken file that CLAIMS to be an archive fails as an archive, not as markdown.
|
||||
with pytest.raises(ValueError, match="zip"):
|
||||
store.stage_upload(b"garbage bytes", filename="broken.zip")
|
||||
# Binary junk with no extension hint → the catch-all names both accepted shapes.
|
||||
with pytest.raises(ValueError, match=r"\.zip or a SKILL\.md"):
|
||||
store.stage_upload(b"\xff\xfe\x00\x01binary junk")
|
||||
|
||||
|
||||
def test_upload_bare_md_with_frontmatter(store):
|
||||
preview = store.stage_upload(SKILL_MD.encode(), filename="greet.md")
|
||||
assert preview["name"] == "greet"
|
||||
assert preview["files"] == []
|
||||
saved = store.confirm_upload(preview["token"], scope="global")
|
||||
assert saved["name"] == "greet"
|
||||
assert store.rows()[0]["source"] == "uploaded"
|
||||
|
||||
|
||||
def test_upload_bare_md_without_name_rejected(store):
|
||||
with pytest.raises(ValueError, match="frontmatter"):
|
||||
store.stage_upload(b"Just instructions, no frontmatter.", filename="notes.md")
|
||||
|
||||
|
||||
def test_upload_mac_finder_zip_junk_stripped(store):
|
||||
"""macOS Finder's Compress injects __MACOSX/._* shadows and .DS_Store — a Mac-made
|
||||
zip must install clean on Windows/Linux, with none of that staged or listed."""
|
||||
preview = store.stage_upload(
|
||||
_zip_bytes(
|
||||
{
|
||||
"greet/SKILL.md": SKILL_MD,
|
||||
"greet/notes.txt": "real resource",
|
||||
"greet/.DS_Store": "junk",
|
||||
"__MACOSX/greet/._SKILL.md": "junk",
|
||||
"__MACOSX/greet/._notes.txt": "junk",
|
||||
}
|
||||
),
|
||||
filename="greet.zip",
|
||||
)
|
||||
assert preview["name"] == "greet"
|
||||
assert preview["files"] == ["notes.txt"] # junk neither listed…
|
||||
saved = store.confirm_upload(preview["token"], scope="global")
|
||||
folder = Path(saved["path"])
|
||||
installed = sorted(p.name for p in folder.rglob("*"))
|
||||
assert installed == ["SKILL.md", "notes.txt"] # …nor installed
|
||||
|
||||
|
||||
def test_upload_zip_slip_rejected(store, tmp_path):
|
||||
with pytest.raises(ValueError, match="unsafe"):
|
||||
store.stage_upload(_zip_bytes({"../evil/SKILL.md": SKILL_MD}))
|
||||
assert not (tmp_path / "evil").exists()
|
||||
|
||||
|
||||
def test_upload_confirm_saves_previewed_content(store):
|
||||
preview = store.stage_upload(
|
||||
_zip_bytes({"greet/SKILL.md": SKILL_MD, "greet/notes.txt": "extra"})
|
||||
)
|
||||
saved = store.confirm_upload(preview["token"], scope="global")
|
||||
assert saved["name"] == "greet"
|
||||
loader = SkillLoader([store.global_dir])
|
||||
assert loader.get("greet").description == preview["description"]
|
||||
assert (store.global_dir / "greet" / "notes.txt").is_file()
|
||||
rows = store.rows()
|
||||
assert rows[0]["source"] == "uploaded" # provenance stamped (SKILLS-SPEC v2 hook)
|
||||
with pytest.raises(ValueError, match="expired"):
|
||||
store.confirm_upload(preview["token"]) # token is one-shot
|
||||
|
||||
|
||||
# -- disable state -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_disable_persists_across_reload(store, monkeypatch, tmp_path):
|
||||
store.create(name="sleepy", description="", instructions="x")
|
||||
store.set_enabled("sleepy", False)
|
||||
reloaded = SkillStore(global_dir=store.global_dir)
|
||||
assert "sleepy" in reloaded.disabled_names()
|
||||
assert reloaded.rows()[0]["enabled"] is False
|
||||
reloaded.set_enabled("sleepy", True)
|
||||
assert reloaded.rows()[0]["enabled"] is True
|
||||
|
||||
|
||||
def test_corrupt_settings_json_treated_as_empty(store):
|
||||
store._settings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
store._settings_path.write_text("{not json", encoding="utf-8")
|
||||
assert store.disabled_names() == set()
|
||||
store.set_enabled("x", False) # recovers by rewriting the file
|
||||
assert store.disabled_names() == {"x"}
|
||||
|
||||
|
||||
# -- save_skill tool (SKILLS-SPEC §5.2 — the worker-authors door) -------------------
|
||||
|
||||
|
||||
from coworker.skills import save_skill_tool # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session_dir(tmp_path):
|
||||
d = tmp_path / "session-root"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
def test_save_skill_adds_a_new_global_skill(store, session_dir):
|
||||
tool = save_skill_tool(store, allowed_dirs=[session_dir])
|
||||
result = tool(
|
||||
name="weekly-report",
|
||||
description="Monday status report",
|
||||
instructions="1. Gather updates\n2. Write the report",
|
||||
)
|
||||
assert result["ok"] and result["action"] == "added"
|
||||
skill = SkillLoader([store.global_dir]).get("weekly-report")
|
||||
assert skill.description == "Monday status report"
|
||||
|
||||
|
||||
def test_save_skill_bundles_files_from_session_roots(store, session_dir):
|
||||
script = session_dir / "fetch_prs.py"
|
||||
script.write_text("print('prs')", encoding="utf-8")
|
||||
example = session_dir / "sub" / "example-report.md"
|
||||
example.parent.mkdir()
|
||||
example.write_text("# Example", encoding="utf-8")
|
||||
tool = save_skill_tool(store, allowed_dirs=[session_dir])
|
||||
result = tool(
|
||||
name="gh-report",
|
||||
description="report",
|
||||
instructions="Run fetch_prs.py",
|
||||
files=[str(script), "sub/example-report.md"], # absolute AND relative both work
|
||||
)
|
||||
assert result["ok"] and sorted(result["files"]) == ["example-report.md", "fetch_prs.py"]
|
||||
folder = store.global_dir / "gh-report"
|
||||
assert (folder / "fetch_prs.py").read_text(encoding="utf-8") == "print('prs')"
|
||||
assert (folder / "example-report.md").is_file()
|
||||
|
||||
|
||||
def test_save_skill_existing_name_updates_and_keeps_resources(store, session_dir):
|
||||
store.create(name="gh-report", description="old", instructions="old body")
|
||||
(store.global_dir / "gh-report" / "keep.txt").write_text("keep", encoding="utf-8")
|
||||
tool = save_skill_tool(store, allowed_dirs=[session_dir])
|
||||
result = tool(name="gh-report", description="new", instructions="new body")
|
||||
assert result["ok"] and result["action"] == "updated"
|
||||
skill = SkillLoader([store.global_dir]).get("gh-report")
|
||||
assert skill.description == "new" and "new body" in skill.instructions
|
||||
assert (store.global_dir / "gh-report" / "keep.txt").is_file() # siblings preserved
|
||||
|
||||
|
||||
def test_save_skill_refuses_files_outside_session_roots(store, session_dir, tmp_path):
|
||||
secret = tmp_path / "outside.txt"
|
||||
secret.write_text("secret", encoding="utf-8")
|
||||
tool = save_skill_tool(store, allowed_dirs=[session_dir])
|
||||
result = tool(name="x", description="d", instructions="i", files=[str(secret)])
|
||||
assert "outside this session's folders" in result["error"]
|
||||
assert not (store.global_dir / "x").exists() # vetting happens BEFORE any disk write
|
||||
|
||||
|
||||
def test_save_skill_validation_errors(store, session_dir):
|
||||
tool = save_skill_tool(store, allowed_dirs=[session_dir])
|
||||
assert "description" in tool(name="x", description=" ", instructions="i")["error"]
|
||||
assert "instructions" in tool(name="x", description="d", instructions=" ")["error"]
|
||||
assert "error" in tool(name="../evil", description="d", instructions="i")
|
||||
# A bundled SKILL.md is skipped silently, never an error: the instructions argument
|
||||
# becomes SKILL.md, and models routinely try to bundle their workspace draft of it —
|
||||
# erroring cost a second approval round (live drive 2026-07-27).
|
||||
(session_dir / "SKILL.md").write_text("x", encoding="utf-8")
|
||||
result = tool(name="x", description="d", instructions="i", files=["SKILL.md"])
|
||||
assert result["ok"] and result["files"] == []
|
||||
skill_md = (store.global_dir / "x" / "SKILL.md").read_text(encoding="utf-8")
|
||||
assert "i" in skill_md and "x" != skill_md # instructions won, draft file ignored
|
||||
|
||||
|
||||
def test_save_skill_requires_approval_metadata(store):
|
||||
tool = save_skill_tool(store)
|
||||
meta = tool.__aisuite_tool_metadata__
|
||||
assert meta.requires_approval is True # → EXTERNAL risk → approval card, every call
|
||||
assert tool.__coworker_schema__["function"]["name"] == "save_skill"
|
||||
required = tool.__coworker_schema__["function"]["parameters"]["required"]
|
||||
assert required == ["name", "description", "instructions"]
|
||||
|
||||
|
||||
def test_rows_report_bundled_file_count(store):
|
||||
store.create(name="plain", description="d", instructions="i")
|
||||
store.create(name="rich", description="d", instructions="i")
|
||||
rich = store.global_dir / "rich"
|
||||
(rich / "fetch.py").write_text("x", encoding="utf-8")
|
||||
(rich / "examples").mkdir()
|
||||
(rich / "examples" / "one.md").write_text("x", encoding="utf-8")
|
||||
by_name = {r["name"]: r for r in store.rows()}
|
||||
assert by_name["plain"]["files"] == 0 # SKILL.md itself is not "bundled"
|
||||
assert by_name["rich"]["files"] == 2 # counted recursively
|
||||
@@ -0,0 +1,178 @@
|
||||
"""`web_fetch` / `browser_read_url` must not reach the machine's own network position.
|
||||
|
||||
Both take a URL straight from the model, and the model's input is untrusted by design —
|
||||
the tools' own descriptions call fetched content "data to evaluate, not instructions".
|
||||
`web_fetch` is additionally `requires_approval=False`, so nothing prompts the user.
|
||||
"""
|
||||
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
from coworker.web import guard
|
||||
from coworker.web.fetch import make_web_fetch_tool
|
||||
|
||||
|
||||
def _resolves_to(monkeypatch, ip: str):
|
||||
monkeypatch.setattr(
|
||||
guard.socket, "getaddrinfo",
|
||||
lambda *a, **k: [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 80))],
|
||||
)
|
||||
|
||||
|
||||
# -- literals -----------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("url,needle", [
|
||||
("http://127.0.0.1:11434/api/tags", "loopback"),
|
||||
("http://localhost:8000/", "loopback"),
|
||||
("http://[::1]:8080/", "loopback"),
|
||||
("http://169.254.169.254/latest/meta-data/", "link-local"),
|
||||
("http://10.0.0.5/admin", "private"),
|
||||
("http://192.168.1.1/", "private"),
|
||||
("http://172.16.4.4/", "private"),
|
||||
("http://0.0.0.0/", "refusing to fetch"), # 0.0.0.0/8 lands in is_private first
|
||||
("http://100.64.0.1/", "CGNAT"), # RFC 6598 shared space (Tailscale, CGNAT)
|
||||
("http://100.127.255.254/", "CGNAT"),
|
||||
])
|
||||
def test_blocked_literals(url, needle):
|
||||
reason = guard.check_url(url)
|
||||
assert reason and needle in reason
|
||||
|
||||
|
||||
def test_cgnat_neighbours_still_allowed(monkeypatch):
|
||||
"""100.64.0.0/10 is blocked, but the adjacent public 100.63/100.128 space is not."""
|
||||
_resolves_to(monkeypatch, "100.63.255.255")
|
||||
assert guard.check_url("http://below.example/") is None
|
||||
_resolves_to(monkeypatch, "100.128.0.0")
|
||||
assert guard.check_url("http://above.example/") is None
|
||||
|
||||
|
||||
def test_ipv4_mapped_ipv6_loopback_is_blocked():
|
||||
"""::ffff:127.0.0.1 must be judged as the v4 address it carries."""
|
||||
assert guard.check_url("http://[::ffff:127.0.0.1]/")
|
||||
|
||||
|
||||
def test_public_literal_is_allowed():
|
||||
assert guard.check_url("https://93.184.216.34/") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://example.com/x",
|
||||
"gopher://example.com/", "http://"])
|
||||
def test_non_http_schemes_and_hostless_urls_are_refused(url):
|
||||
assert guard.check_url(url)
|
||||
|
||||
|
||||
# -- names --------------------------------------------------------------------
|
||||
|
||||
def test_hostname_resolving_to_loopback_is_blocked(monkeypatch):
|
||||
"""`localtest.me` and friends are public names with private answers."""
|
||||
_resolves_to(monkeypatch, "127.0.0.1")
|
||||
assert "loopback" in guard.check_url("http://sneaky.example.com/")
|
||||
|
||||
|
||||
def test_hostname_resolving_to_metadata_ip_is_blocked(monkeypatch):
|
||||
_resolves_to(monkeypatch, "169.254.169.254")
|
||||
assert guard.check_url("http://metadata.example.com/")
|
||||
|
||||
|
||||
def test_any_private_answer_blocks_a_split_horizon_name(monkeypatch):
|
||||
"""One public and one private A record must not be a way through."""
|
||||
monkeypatch.setattr(
|
||||
guard.socket, "getaddrinfo",
|
||||
lambda *a, **k: [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 80)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 80)),
|
||||
],
|
||||
)
|
||||
assert guard.check_url("http://split.example.com/")
|
||||
|
||||
|
||||
def test_public_hostname_is_allowed(monkeypatch):
|
||||
_resolves_to(monkeypatch, "93.184.216.34")
|
||||
assert guard.check_url("https://example.com/docs") is None
|
||||
|
||||
|
||||
def test_unresolvable_host_is_refused_not_fetched(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise socket.gaierror("nodename nor servname provided")
|
||||
monkeypatch.setattr(guard.socket, "getaddrinfo", boom)
|
||||
assert "could not resolve" in guard.check_url("http://nope.invalid/")
|
||||
|
||||
|
||||
# -- redirects ----------------------------------------------------------------
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status=200, location=None, url="https://example.com/"):
|
||||
self.status_code = status
|
||||
self.headers = {"location": location} if location else {}
|
||||
self.url = _Url(url)
|
||||
self.text = "body"
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Url(str):
|
||||
def join(self, other):
|
||||
return other
|
||||
|
||||
|
||||
class _Client:
|
||||
"""Records what was actually requested, so a blocked hop is provably not fetched."""
|
||||
|
||||
def __init__(self, script):
|
||||
self.script = script
|
||||
self.requested = []
|
||||
|
||||
def get(self, url):
|
||||
self.requested.append(url)
|
||||
return self.script.pop(0)
|
||||
|
||||
|
||||
def test_redirect_into_loopback_is_blocked_before_the_second_request(monkeypatch):
|
||||
_resolves_to(monkeypatch, "93.184.216.34")
|
||||
client = _Client([_Resp(302, location="http://127.0.0.1:11434/api/tags")])
|
||||
with pytest.raises(PermissionError, match="loopback"):
|
||||
guard.get_checked(client, "https://example.com/start")
|
||||
assert client.requested == ["https://example.com/start"], (
|
||||
"the redirect target must never be requested"
|
||||
)
|
||||
|
||||
|
||||
def test_allowed_redirect_chain_is_followed(monkeypatch):
|
||||
_resolves_to(monkeypatch, "93.184.216.34")
|
||||
client = _Client([_Resp(302, location="https://example.com/b"), _Resp(200)])
|
||||
resp = guard.get_checked(client, "https://example.com/a")
|
||||
assert resp.status_code == 200
|
||||
assert client.requested == ["https://example.com/a", "https://example.com/b"]
|
||||
|
||||
|
||||
def test_redirect_loop_is_bounded(monkeypatch):
|
||||
_resolves_to(monkeypatch, "93.184.216.34")
|
||||
client = _Client([_Resp(302, location="https://example.com/loop")] * 50)
|
||||
with pytest.raises(RuntimeError, match="too many redirects"):
|
||||
guard.get_checked(client, "https://example.com/loop")
|
||||
|
||||
|
||||
# -- the tool -----------------------------------------------------------------
|
||||
|
||||
def test_web_fetch_returns_the_refusal_as_a_tool_error(monkeypatch):
|
||||
_resolves_to(monkeypatch, "127.0.0.1")
|
||||
out = make_web_fetch_tool()("http://sneaky.example.com/")
|
||||
assert "loopback" in out["error"]
|
||||
assert "text" not in out
|
||||
|
||||
|
||||
def test_web_fetch_still_rejects_non_http_schemes():
|
||||
assert "http" in make_web_fetch_tool()("file:///etc/passwd")["error"]
|
||||
|
||||
|
||||
def test_browser_open_url_is_guarded_and_never_launches(monkeypatch):
|
||||
"""The Playwright browser_open_url is approval gated, but the address guard still
|
||||
refuses a blocked URL before the browser is touched (defense in depth)."""
|
||||
from coworker.connectors.browser_automation import make_browser_automation_tools
|
||||
|
||||
open_url = {t.__name__: t for t in make_browser_automation_tools()}["browser_open_url"]
|
||||
out = open_url("http://169.254.169.254/latest/meta-data/")
|
||||
assert "link-local" in out["error"]
|
||||
assert out.get("ok") is None
|
||||
Reference in New Issue
Block a user