diff --git a/coworker/personas/builtin/appsec-worker/manifest.md b/coworker/personas/builtin/appsec-worker/manifest.md index 88554104..dd57bf73 100644 --- a/coworker/personas/builtin/appsec-worker/manifest.md +++ b/coworker/personas/builtin/appsec-worker/manifest.md @@ -1,4 +1,5 @@ --- +ships: false id: appsec-worker name: AppSec Worker icon: code diff --git a/coworker/personas/builtin/change-worker/manifest.md b/coworker/personas/builtin/change-worker/manifest.md index 59714875..eb50467e 100644 --- a/coworker/personas/builtin/change-worker/manifest.md +++ b/coworker/personas/builtin/change-worker/manifest.md @@ -1,4 +1,5 @@ --- +ships: false id: change-worker name: Change Worker icon: code diff --git a/coworker/personas/builtin/cloud-posture/manifest.md b/coworker/personas/builtin/cloud-posture/manifest.md index be1b201a..165a2cae 100644 --- a/coworker/personas/builtin/cloud-posture/manifest.md +++ b/coworker/personas/builtin/cloud-posture/manifest.md @@ -1,4 +1,5 @@ --- +group: security id: cloud-posture name: Cloud Posture Coworker icon: sliders diff --git a/coworker/personas/builtin/dep-audit/manifest.md b/coworker/personas/builtin/dep-audit/manifest.md index 3dfc510f..bc410009 100644 --- a/coworker/personas/builtin/dep-audit/manifest.md +++ b/coworker/personas/builtin/dep-audit/manifest.md @@ -1,4 +1,5 @@ --- +group: security id: dep-audit name: Dependency Audit Coworker icon: audit diff --git a/coworker/personas/builtin/design-worker/manifest.md b/coworker/personas/builtin/design-worker/manifest.md index 12c6dab8..e6c5f841 100644 --- a/coworker/personas/builtin/design-worker/manifest.md +++ b/coworker/personas/builtin/design-worker/manifest.md @@ -1,4 +1,5 @@ --- +ships: false id: design-worker name: Design Worker icon: layout diff --git a/coworker/personas/builtin/devops-lead/manifest.md b/coworker/personas/builtin/devops-lead/manifest.md index cee4079a..cf6fc42c 100644 --- a/coworker/personas/builtin/devops-lead/manifest.md +++ b/coworker/personas/builtin/devops-lead/manifest.md @@ -1,4 +1,5 @@ --- +ships: false id: devops-lead name: DevOps Lead icon: audit diff --git a/coworker/personas/builtin/devsecops-lead/manifest.md b/coworker/personas/builtin/devsecops-lead/manifest.md index 9b0578ff..cec3e010 100644 --- a/coworker/personas/builtin/devsecops-lead/manifest.md +++ b/coworker/personas/builtin/devsecops-lead/manifest.md @@ -1,4 +1,5 @@ --- +ships: false id: devsecops-lead name: DevSecOps Lead icon: shield diff --git a/coworker/personas/builtin/infra-worker/manifest.md b/coworker/personas/builtin/infra-worker/manifest.md index f92b8aed..0fc6c319 100644 --- a/coworker/personas/builtin/infra-worker/manifest.md +++ b/coworker/personas/builtin/infra-worker/manifest.md @@ -1,4 +1,5 @@ --- +ships: false id: infra-worker name: Infra Worker icon: sliders diff --git a/coworker/personas/builtin/logs-worker/manifest.md b/coworker/personas/builtin/logs-worker/manifest.md index 3f01d644..ebcf930a 100644 --- a/coworker/personas/builtin/logs-worker/manifest.md +++ b/coworker/personas/builtin/logs-worker/manifest.md @@ -1,4 +1,5 @@ --- +ships: false id: logs-worker name: Logs Worker icon: search diff --git a/coworker/personas/builtin/ops.md b/coworker/personas/builtin/ops.md index 2926aa56..242baf10 100644 --- a/coworker/personas/builtin/ops.md +++ b/coworker/personas/builtin/ops.md @@ -1,4 +1,5 @@ --- +ships: false id: ops name: Ops Coworker icon: wrench diff --git a/coworker/personas/builtin/posture-worker/manifest.md b/coworker/personas/builtin/posture-worker/manifest.md index 1a03e3af..542daf1b 100644 --- a/coworker/personas/builtin/posture-worker/manifest.md +++ b/coworker/personas/builtin/posture-worker/manifest.md @@ -1,4 +1,5 @@ --- +ships: false id: posture-worker name: Posture Worker icon: sliders diff --git a/coworker/personas/builtin/secrets-worker/manifest.md b/coworker/personas/builtin/secrets-worker/manifest.md index 367555ea..0880fab3 100644 --- a/coworker/personas/builtin/secrets-worker/manifest.md +++ b/coworker/personas/builtin/secrets-worker/manifest.md @@ -1,4 +1,5 @@ --- +ships: false id: secrets-worker name: Secrets Worker icon: search diff --git a/coworker/personas/builtin/security/manifest.md b/coworker/personas/builtin/security/manifest.md index ad20c133..1c1dbd23 100644 --- a/coworker/personas/builtin/security/manifest.md +++ b/coworker/personas/builtin/security/manifest.md @@ -1,4 +1,5 @@ --- +group: security id: security name: Security Coworker icon: shield diff --git a/coworker/personas/builtin/swe-lead/manifest.md b/coworker/personas/builtin/swe-lead/manifest.md index f1fb994e..5627dde6 100644 --- a/coworker/personas/builtin/swe-lead/manifest.md +++ b/coworker/personas/builtin/swe-lead/manifest.md @@ -1,4 +1,5 @@ --- +ships: false id: swe-lead name: SWE Lead icon: users diff --git a/coworker/personas/builtin/swe-worker/manifest.md b/coworker/personas/builtin/swe-worker/manifest.md index da65a8df..315bbaf2 100644 --- a/coworker/personas/builtin/swe-worker/manifest.md +++ b/coworker/personas/builtin/swe-worker/manifest.md @@ -1,4 +1,5 @@ --- +ships: false id: swe-worker name: SWE Worker icon: code diff --git a/coworker/personas/builtin/test-worker/manifest.md b/coworker/personas/builtin/test-worker/manifest.md index 9935ea86..82ec780a 100644 --- a/coworker/personas/builtin/test-worker/manifest.md +++ b/coworker/personas/builtin/test-worker/manifest.md @@ -1,4 +1,5 @@ --- +ships: false id: test-worker name: Test Worker icon: check diff --git a/coworker/personas/manifest.py b/coworker/personas/manifest.py index d3aa47f7..80a81e15 100644 --- a/coworker/personas/manifest.py +++ b/coworker/personas/manifest.py @@ -26,6 +26,7 @@ VALID_WORKSPACES = {"git", "project", "deliverable", "none"} VALID_MODES = {"discuss", "plan", "interactive", "custom", "auto"} VALID_REC_KINDS = {"connector", "mcp"} VALID_REC_TIERS = {"core", "optional"} +VALID_GROUPS = {"general", "security"} class ManifestError(ValueError): @@ -80,6 +81,13 @@ class PersonaManifest: # update channel, so this drives the "replaces vN" note on re-install, nothing more. version: str = "" recommends: list[Recommendation] = field(default_factory=list) + # Distribution decision, not a maturity claim (owner, 2026-08-21): ships:false + # coworkers exist in the codebase but are absent from release builds — internal + # builds opt them in via OPENWORKER_UNSHIPPED=1. + ships: bool = True + # Settings-page grouping ("general" | "security"). Cosmetic — grouping never + # gates behavior, so a third-party persona claiming "security" is harmless. + group: str = "general" builtin: bool = False source: Optional[str] = ( None # where it was loaded from (path / url), for provenance @@ -288,6 +296,12 @@ def parse_manifest( f"persona {persona_id!r}: default_permission_mode must be one of {sorted(VALID_MODES)}" ) + group = str(meta.get("group", "general") or "general").strip().lower() + if group not in VALID_GROUPS: + raise ManifestError( + f"persona {persona_id!r}: group must be one of {sorted(VALID_GROUPS)}" + ) + team_raw = str(meta.get("team", "") or "").strip().lower() if team_raw and team_raw not in VALID_TEAM: raise ManifestError( @@ -319,6 +333,8 @@ def parse_manifest( mcp=_strlist(meta, "mcp"), version=str(meta.get("version", "") or "").strip(), recommends=recommends, + ships=bool(meta.get("ships", True)), + group=group, builtin=builtin, source=source, ) diff --git a/coworker/personas/registry.py b/coworker/personas/registry.py index 52432d1c..fdf9f84f 100644 --- a/coworker/personas/registry.py +++ b/coworker/personas/registry.py @@ -1,7 +1,7 @@ """Persona registry — the installed personas + their lifecycle state. -Unifies two sources behind one `id → Agent` resolver: the core surfaces (Code / Chat / -Cowork) wrap their existing agent builders (exact prompts preserved), and markdown manifests +Unifies two sources behind one `id → Agent` resolver: the core surfaces (Cowork / Code) +wrap their existing agent builders (exact prompts preserved), and markdown manifests (Ops today; third-party dirs in Phase 2) load through ``PersonaManifest``. Lifecycle — installed → enabled → surfaced, plus a default — is persisted to a small JSON file. @@ -13,13 +13,13 @@ working. Disable/surface only affect what the *new-session* picker offers. from __future__ import annotations import json +import os import shutil from dataclasses import dataclass, field from pathlib import Path from typing import Callable, Optional from ..agents.base import Agent -from ..agents.chat import chat_agent from ..agents.code import CODE_CAPABILITIES, code_agent from ..agents.cowork import COWORK_CAPABILITIES, cowork_agent from .manifest import PersonaManifest, load_manifest_file @@ -27,6 +27,16 @@ from .manifest import PersonaManifest, load_manifest_file DEFAULT_PERSONA_ID = "cowork" +def include_unshipped() -> bool: + """Internal builds opt ships:false coworkers in (owner, 2026-08-21). A release + build never sets this, so unshipped personas simply do not exist there.""" + return os.environ.get("OPENWORKER_UNSHIPPED", "").strip().lower() not in ( + "", + "0", + "false", + ) + + @dataclass class PersonaState: enabled: bool = True @@ -51,9 +61,13 @@ class PersonaEntry: True # whether it shows in the picker before any user choice ) # Whether it ships enabled before any user choice. Builtins default on (UX-029: the - # composer picker is their front door) — except retired ones (Chat: Coworker covers - # quick Q&A). Installed third-party personas always start disabled pending consent. + # composer picker is their front door) — except Code (owner call 2026-08-21: ships + # disabled). Installed third-party personas always start disabled pending consent. default_enabled: bool = True + # Distribution flag (owner, 2026-08-21): ships:false = absent from release builds. + ships: bool = True + # Settings-page grouping ("general" | "security") — cosmetic only. + group: str = "general" _builder: Optional[Callable[[], Agent]] = None manifest: Optional[PersonaManifest] = None @@ -109,6 +123,7 @@ class PersonaRegistry: workspace="deliverable", default_surfaced=True, default_enabled=True, + group="general", ) -> None: self._entries[id] = PersonaEntry( id=id, @@ -122,13 +137,17 @@ class PersonaRegistry: tools=list(tools), default_surfaced=default_surfaced, default_enabled=default_enabled, + group=group, _builder=builder, ) def _load_builtin(self, builtin_dir: Optional[str | Path]) -> None: - # Core surfaces keep their exact prompts via the existing builders. Cowork (the default) - # leads; Chat is RETIRED (owner call 2026-08-11: Coworker covers quick Q&A) — it ships - # disabled and unsurfaced, recoverable from Settings ▸ Coworkers. + # Core surfaces keep their exact prompts via the existing builders. Cowork (the + # default) leads. Chat is GONE (owner call 2026-08-21; retired-but-listed since + # 2026-08-11) — stray `persona=chat` session ids resolve to the default via + # agent()'s unknown-id fallback. Code ships disabled + unsurfaced (same owner + # call): OpenWorker is the launch generalist, but Code stays one checkbox away + # as the only plain work-in-my-repo persona. self._register_builder( "cowork", "OpenWorker", @@ -150,17 +169,6 @@ class PersonaRegistry: "code", CODE_CAPABILITIES, workspace="git", - ) - self._register_builder( - "chat", - "Chat", - "chat", - "Quick questions — no workspace", - chat_agent, - False, - "knowledge", - [], - workspace="none", default_surfaced=False, default_enabled=False, ) @@ -197,6 +205,8 @@ class PersonaRegistry: family=m.family, workspace=m.workspace, tools=list(m.tools), + ships=m.ships, + group=m.group, manifest=m, # Team workers never surface in the picker: they are purpose-built to be # STAFFED by a lead, not started solo (their prompts talk to a lead, not @@ -237,18 +247,32 @@ class PersonaRegistry: ) # -- queries ---------------------------------------------------------------- + def _visible(self, e: PersonaEntry) -> bool: + # Unshipped personas surface only on internal builds — except one a user + # already enabled (an internal-build choice must not vanish under them). + return e.ships or include_unshipped() or self._enabled.get(e.id) is True + def ids(self) -> list[str]: return list(self._entries) def get(self, persona_id: str) -> Optional[PersonaEntry]: return self._entries.get(persona_id) + def media_dir(self, persona_id: str) -> Optional[Path]: + """The persona bundle's media/ folder (screenshots for the detail page), if any. + Only manifest-backed personas have one — it sits beside their manifest.md.""" + entry = self._entries.get(persona_id) + if entry is None or entry.manifest is None or not entry.manifest.source: + return None + d = Path(entry.manifest.source).parent / "media" + return d if d.is_dir() else None + def is_enabled(self, persona_id: str) -> bool: # Explicit state (either way) always wins. Absent a user choice, the entry's # default applies: builtins ship enabled — the composer picker is their front door # (UX-029, supersedes the 2026-07-09 Coworker-only default that fit the old hidden - # ▾ menu) — except retired ones (Chat). Installed third-party personas stay - # disabled until the user consents from the risk screen. + # ▾ menu) — except ones registered default-off (Code). Installed third-party + # personas stay disabled until the user consents from the risk screen. if persona_id in self._enabled: return bool(self._enabled[persona_id]) entry = self._entries.get(persona_id) @@ -288,7 +312,7 @@ class PersonaRegistry: """Session surfaces for the new-session picker: enabled AND surfaced, in order.""" out = [] for e in self._entries.values(): - if self.is_enabled(e.id) and self.is_surfaced(e.id): + if self._visible(e) and self.is_enabled(e.id) and self.is_surfaced(e.id): out.append( { "name": e.id, @@ -317,10 +341,13 @@ class PersonaRegistry: "enabled": self.is_enabled(e.id), "surfaced": self.is_surfaced(e.id), "default": e.id == self.default_id(), + "ships": e.ships, + "group": e.group, "version": e.manifest.version if e.manifest else "", "installed_at": self._installed_meta.get(e.id, {}).get("installed_at", ""), } for e in self._entries.values() + if self._visible(e) ] # -- mutations -------------------------------------------------------------- diff --git a/coworker/server/app.py b/coworker/server/app.py index ffceba1a..11cfc1d2 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -256,7 +256,11 @@ def create_app(manager: SessionManager) -> FastAPI: @app.get("/v1/personas") def personas() -> dict[str, Any]: - return {"personas": manager.personas.list_all()} + from ..personas.registry import include_unshipped + + # `internal` tells the GUI it may show internal-build affordances (the + # "Not in this release" group, the Gallery entry point). + return {"personas": manager.personas.list_all(), "internal": include_unshipped()} @app.get("/v1/inbox") def inbox(session_id: str = "", state: str = "") -> dict[str, Any]: @@ -581,6 +585,24 @@ def create_app(manager: SessionManager) -> FastAPI: return {"ok": False, "error": f"unknown persona: {persona_id}"} return detail + @app.get("/v1/personas/{persona_id}/media/{name}") + def persona_media(persona_id: str, name: str) -> Any: + # Screenshots from the persona bundle's media/ folder. The name is confined + # to that folder: no separators, resolved path must stay inside it. + from fastapi.responses import FileResponse, Response + + media_dir = manager.personas.media_dir(persona_id) + if media_dir is None or "/" in name or "\\" in name or name.startswith("."): + return Response(status_code=404) + f = (media_dir / name).resolve() + try: + inside = f.is_relative_to(media_dir.resolve()) + except AttributeError: # pragma: no cover — py<3.9 has no is_relative_to + inside = str(f).startswith(str(media_dir.resolve())) + if not inside or not f.is_file(): + return Response(status_code=404) + return FileResponse(f) + @app.post("/v1/personas/{persona_id}/enable") def persona_enable(persona_id: str, body: dict) -> dict[str, Any]: # Dedicated §5/§8 route; delegates to the same manager toggle as POST /v1/personas/{id} diff --git a/coworker/server/manager.py b/coworker/server/manager.py index f63a9ee3..190ce748 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -784,13 +784,28 @@ class SessionManager: } for rec in (manifest.recommends if manifest else []) ] + media_dir = self.personas.media_dir(persona_id) + media = ( + sorted( + f.name + for f in media_dir.iterdir() + if f.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} + ) + if media_dir + else [] + ) return { "id": entry.id, "name": entry.name, "icon": entry.icon, "tagline": entry.tagline, "description": manifest.description if manifest else "", + "media": media, + "builtin": entry.builtin, + "group": entry.group, "enabled": self.personas.is_enabled(entry.id), + "surfaced": self.personas.is_surfaced(entry.id), + "default": entry.id == self.personas.default_id(), "tools": list(entry.tools), "recommended_models": list(manifest.recommended_models) if manifest else [], "default_permission_mode": ( @@ -1719,8 +1734,11 @@ class SessionManager: propose_team). Only team-capable workers are listed — solo coworkers cannot join a team.""" out = [] - for row in manager.personas.list_all(): - pid = row.get("id", "") + # Registry entries directly — NOT list_all(), which applies the + # ships:false visibility filter: a lead that is running (internal + # build or user-enabled) must be able to staff its workers even + # when those workers are hidden from the settings page. + for pid in manager.personas.ids(): entry = manager.personas.get(pid) m = getattr(entry, "manifest", None) if m is None or m.team != "worker": diff --git a/tests/test_devops_team.py b/tests/test_devops_team.py index aec9409d..7ca4e697 100644 --- a/tests/test_devops_team.py +++ b/tests/test_devops_team.py @@ -25,7 +25,8 @@ def test_bundle_registers_with_team_traits(tmp_path): assert reg.get(pid).manifest.team == "worker" -def test_lead_surfaces_workers_do_not(tmp_path): +def test_lead_surfaces_workers_do_not(tmp_path, monkeypatch): + monkeypatch.setenv("OPENWORKER_UNSHIPPED", "1") # teams are ships:false — internal builds reg = _reg(tmp_path) ids = [e["name"] for e in reg.sidebar()] assert "devops-lead" in ids diff --git a/tests/test_devsecops_team.py b/tests/test_devsecops_team.py index d447d4d1..4436cf31 100644 --- a/tests/test_devsecops_team.py +++ b/tests/test_devsecops_team.py @@ -38,7 +38,8 @@ def test_lead_carries_no_execution_tools(tmp_path): assert {"shell", "git"} <= set(reg.get(pid).tools) -def test_workers_never_surface_lead_does(tmp_path): +def test_workers_never_surface_lead_does(tmp_path, monkeypatch): + monkeypatch.setenv("OPENWORKER_UNSHIPPED", "1") # teams are ships:false — internal builds reg = _reg(tmp_path) ids = [e["name"] for e in reg.sidebar()] assert "devsecops-lead" in ids diff --git a/tests/test_persona_connections.py b/tests/test_persona_connections.py index c07b0617..f1f9b5c6 100644 --- a/tests/test_persona_connections.py +++ b/tests/test_persona_connections.py @@ -127,6 +127,7 @@ def test_persona_set_default_connection(tmp_path, monkeypatch): def test_persona_enable_toggle(tmp_path, monkeypatch): + monkeypatch.setenv("OPENWORKER_UNSHIPPED", "1") # ops is ships:false now mgr = _mgr(tmp_path, monkeypatch) client = TestClient(create_app(mgr)) diff --git a/tests/test_persona_registry.py b/tests/test_persona_registry.py index 5cccbd1e..c2e7f9f0 100644 --- a/tests/test_persona_registry.py +++ b/tests/test_persona_registry.py @@ -1,4 +1,6 @@ -"""Phase 1 gate — persona registry lifecycle (installed → enabled → surfaced + default).""" +"""Phase 1 gate — persona registry lifecycle (installed → enabled → surfaced + default), +plus the shipping lineup (owner calls 2026-08-21): Chat removed, Code disabled by default, +ships:false personas hidden outside internal builds (OPENWORKER_UNSHIPPED=1).""" from __future__ import annotations @@ -11,26 +13,62 @@ def _reg(tmp_path) -> PersonaRegistry: return PersonaRegistry(state_path=tmp_path / "personas.json") +@pytest.fixture +def internal(monkeypatch): + """Internal build: ships:false personas (teams, ops, design…) are visible.""" + monkeypatch.setenv("OPENWORKER_UNSHIPPED", "1") + + def test_builtins_present(tmp_path): reg = _reg(tmp_path) - assert {"code", "chat", "cowork", "ops"} <= set(reg.ids()) + assert {"code", "cowork", "ops"} <= set(reg.ids()) + assert "chat" not in reg.ids() # removed entirely, not just disabled assert reg.get("ops").builtin is True # Ops came from a markdown manifest; Code from a builder. assert reg.get("ops").manifest is not None assert reg.get("code").manifest is None -def test_sidebar_defaults_to_surfaced_builtins(tmp_path): +def test_release_lineup(tmp_path, monkeypatch): + # A release build (no flag) offers exactly OpenWorker + the security coworkers; + # Code is listed in Settings but disabled + unsurfaced (the recovery path). + monkeypatch.delenv("OPENWORKER_UNSHIPPED", raising=False) + reg = _reg(tmp_path) + assert [e["name"] for e in reg.sidebar()] == [ + "cowork", "cloud-posture", "dep-audit", "security", + ] + listed = {p["id"]: p for p in reg.list_all()} + assert set(listed) == {"cowork", "code", "cloud-posture", "dep-audit", "security"} + assert listed["code"]["enabled"] is False and listed["code"]["surfaced"] is False + assert listed["cloud-posture"]["group"] == "security" + assert listed["cowork"]["group"] == "general" + # Enabling Code from Settings puts it in the picker (enable implies surface). + reg.set_enabled("code", True) + assert "code" in [e["name"] for e in reg.sidebar()] + + +def test_unshipped_hidden_unless_enabled(tmp_path, monkeypatch): + monkeypatch.delenv("OPENWORKER_UNSHIPPED", raising=False) + reg = _reg(tmp_path) + assert "swe-lead" not in {p["id"] for p in reg.list_all()} + # Still resolvable (a session born on it keeps working)… + assert reg.agent("swe-lead").name == "swe-lead" + # …and an explicit user enable (made on an internal build) keeps it visible. + reg.set_enabled("swe-lead", True) + assert "swe-lead" in {p["id"] for p in reg.list_all()} + + +def test_sidebar_defaults_to_surfaced_builtins(tmp_path, internal): reg = _reg(tmp_path) sidebar = reg.sidebar() ids = [e["name"] for e in sidebar] - # Built-ins ship enabled (UX-029: the coworker picker is their front door); Chat - # stays default-hidden via the surfaced axis. Installed personas remain opt-in. + # Built-ins ship enabled (UX-029: the coworker picker is their front door) except + # Code (owner 2026-08-21). Installed personas remain opt-in. assert ids[0] == "cowork" # Leads surface (the user's entry to a team — "the team IS the lead"); team # workers never do. assert set(ids) == { - "cowork", "code", "ops", "security", "cloud-posture", "dep-audit", + "cowork", "ops", "security", "cloud-posture", "dep-audit", "swe-lead", "devsecops-lead", "devops-lead", } assert not any( @@ -43,23 +81,30 @@ def test_sidebar_defaults_to_surfaced_builtins(tmp_path): ) assert sidebar[0]["default"] is True # An explicit disable removes a builtin from the picker. - reg.set_enabled("code", False) - assert "code" not in [e["name"] for e in reg.sidebar()] + reg.set_enabled("security", False) + assert "security" not in [e["name"] for e in reg.sidebar()] -def test_chat_retired_by_default_but_resolvable(tmp_path): +def test_code_ships_disabled_but_recoverable(tmp_path): reg = _reg(tmp_path) - # Chat is retired (owner call 2026-08-11): disabled + unsurfaced out of the box, - # unlike the other builtins — Coworker covers quick Q&A. - assert reg.is_enabled("chat") is False - assert reg.is_surfaced("chat") is False - assert reg.agent("chat").name == "chat" # live sessions keep resolving - # Still recoverable from Settings ▸ Coworkers (enable implies surface). - reg.set_enabled("chat", True) - assert "chat" in [e["name"] for e in reg.sidebar()] + # Code ships disabled + unsurfaced (owner call 2026-08-21): OpenWorker leads the + # launch, Code stays one checkbox away as the plain work-in-my-repo persona. + assert reg.is_enabled("code") is False + assert reg.is_surfaced("code") is False + assert reg.agent("code").name == "code" # live sessions keep resolving + reg.set_enabled("code", True) + assert "code" in [e["name"] for e in reg.sidebar()] -def test_surface_toggle_filters_picker_but_keeps_resolvable(tmp_path): +def test_chat_gone_resolves_to_default(tmp_path): + reg = _reg(tmp_path) + # Chat is removed outright; a stray persona=chat session id falls back to the + # default persona instead of erroring. + assert reg.get("chat") is None + assert reg.agent("chat").name == reg.default_id() + + +def test_surface_toggle_filters_picker_but_keeps_resolvable(tmp_path, internal): reg = _reg(tmp_path) reg.set_surfaced("ops", False) assert "ops" not in [e["name"] for e in reg.sidebar()] @@ -98,14 +143,13 @@ def test_agent_resolution(tmp_path): assert reg.agent("does-not-exist").name == reg.default_id() -def test_list_all_carries_workspace_enum(tmp_path): - # Post-§16 collapse: workspace derives from family — code → git, knowledge → deliverable - # (scratch). Only builder-registered Chat keeps "none". Ops is a scratch persona now. +def test_list_all_carries_workspace_enum(tmp_path, internal): + # Post-§16 collapse: workspace derives from family — code → git, knowledge → + # deliverable (scratch). Ops is a scratch persona now. reg = _reg(tmp_path) ws = {p["id"]: p["workspace"] for p in reg.list_all()} assert ws["code"] == "git" assert ws["cowork"] == "deliverable" - assert ws["chat"] == "none" assert ws["ops"] == "deliverable" diff --git a/tests/test_server.py b/tests/test_server.py index 40f15567..97563785 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -60,17 +60,12 @@ def test_chat_completions_openai_shape(tmp_path): def test_agents_and_memory_rest(tmp_path): client = _client(tmp_path, []) agents = client.get("/v1/agents").json()["agents"] - # The picker lists enabled+surfaced personas — builtins ship enabled (UX-029); - # Chat stays default-hidden via the surfaced axis. The security bundles (Phase C) - # ship in the picker out of the box. + # The picker lists enabled+surfaced personas. Release lineup (owner 2026-08-21): + # OpenWorker + the security bundles; Code ships disabled, Chat is gone, and + # ships:false personas (teams, ops, design) need OPENWORKER_UNSHIPPED=1. names = [a["name"] for a in agents] assert names[0] == "cowork" - # Leads surface (they're how a user starts a team); team WORKERS never do — - # they're staffed by a lead, not started solo. - assert set(names) == { - "cowork", "code", "ops", "security", "cloud-posture", "dep-audit", - "swe-lead", "devsecops-lead", "devops-lead", - } + assert set(names) == {"cowork", "security", "cloud-posture", "dep-audit"} assert "skills" in client.get("/v1/skills").json() # catalog (may be empty) added = client.post("/v1/memory", json={"content": "prefer pathlib"}).json() @@ -99,17 +94,17 @@ def test_disable_persona_archives_its_sessions(tmp_path): ) ) - mk("chat-a", "chat") - mk("chat-b", "chat") - mk("chat-old", "chat") + mk("chat-a", "code") + mk("chat-b", "code") + mk("chat-old", "code") store.set_flags( "chat-old", archived=True ) # already archived — must not be re-counted mk("cowork-a", "cowork") - mk("__run__r1", "chat") # internal automation thread — never touched + mk("__run__r1", "code") # internal automation thread — never touched client = TestClient(create_app(manager)) - body = client.post("/v1/personas/chat", json={"enabled": False}).json() + body = client.post("/v1/personas/code", json={"enabled": False}).json() assert body["ok"] is True assert body["archived_sessions"] == 2 assert store.load("chat-a").archived and store.load("chat-b").archived @@ -121,8 +116,8 @@ def test_disable_persona_archives_its_sessions(tmp_path): assert store.load("chat-a").archived # The dedicated §5/§8 enable route shares the same semantic. - mk("chat-c", "chat") - client.post("/v1/personas/chat/enable", json={"enabled": False}) + mk("chat-c", "code") + client.post("/v1/personas/code/enable", json={"enabled": False}) assert store.load("chat-c").archived @@ -869,18 +864,19 @@ def test_ws_with_workspace_query(tmp_path): assert "turn_end" in _drain(ws) -def test_ws_chat_agent_needs_no_workspace(tmp_path): +def test_ws_removed_agent_id_falls_back_to_default(tmp_path): + # Chat is removed (owner 2026-08-21): a stored session or deep link carrying + # agent=chat resolves to the default persona instead of erroring. manager = SessionManager( workspace=None, data_dir=tmp_path, - provider=ScriptedProvider([_text("hi from chat")]), + provider=ScriptedProvider([_text("hi")]), ) client = TestClient(create_app(manager)) with client.websocket_connect("/ws/session/chat1?agent=chat") as ws: ready = ws.receive_json() assert ready["type"] == "ready" - assert ready["data"]["agent"] == "chat" - assert ready["data"]["workspace"] is None + assert ready["data"]["agent"] == "cowork" ws.send_json({"type": "user_message", "text": "hello"}) assert "turn_end" in _drain(ws) diff --git a/tests/test_skills.py b/tests/test_skills.py index 203d163f..ac7cdfa9 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -45,8 +45,9 @@ def test_chat_agent_has_no_workspace_tools(): def test_get_agent_fallback(): - assert get_agent("chat").name == "chat" - # Unknown ids fall back to the default persona (Cowork), per the persona registry. + # Chat is removed (owner 2026-08-21): its id, like any unknown id, falls back to + # the default persona per the registry. + assert get_agent("chat").name == "cowork" assert get_agent("nope").name == "cowork" diff --git a/tests/test_skills_api.py b/tests/test_skills_api.py index 88360c58..810db739 100644 --- a/tests/test_skills_api.py +++ b/tests/test_skills_api.py @@ -272,10 +272,10 @@ def test_engine_catalog_respects_settings_disable(tmp_path): client.patch("/v1/skills/hidden", json={"enabled": False}) from coworker.agent import build_engine - from coworker.agents.registry import get_agent + from coworker.agents.chat import chat_agent engine = build_engine( - agent=get_agent("chat"), + agent=chat_agent(), # workspace-free agent (persona retired; builder remains) provider=ScriptedProvider(), skill_filter=lambda: manager.effective_skill_names("s1"), ) diff --git a/tests/test_skills_sessions.py b/tests/test_skills_sessions.py index d53bf4f1..f3df2898 100644 --- a/tests/test_skills_sessions.py +++ b/tests/test_skills_sessions.py @@ -149,11 +149,11 @@ def test_live_load_skill_semantics(manager): 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 + from coworker.agents.chat import chat_agent _skill(manager.skill_store.global_dir, "early", body="early body") engine = build_engine( - agent=get_agent("chat"), + agent=chat_agent(), # workspace-free agent (persona retired; builder remains) provider=ScriptedProvider(), skill_filter=lambda: manager.effective_skill_names("s1"), ) @@ -198,12 +198,12 @@ def test_disable_countermand_for_loaded_skills(manager): import json as _json from coworker.agent import build_engine - from coworker.agents.registry import get_agent + from coworker.agents.chat import chat_agent _skill(manager.skill_store.global_dir, "used-one", body="used body") _skill(manager.skill_store.global_dir, "unused-one", body="never loaded") engine = build_engine( - agent=get_agent("chat"), + agent=chat_agent(), # workspace-free agent (persona retired; builder remains) provider=ScriptedProvider(), skill_filter=lambda: manager.effective_skill_names("s1"), )