"""OPE-97 wake plumbing: the team trait, delivery cursors, lead subscriptions, the registry + budget gate, pre-spawn at staffing, and digests.""" import pytest from coworker.personas.loading import capability_set from coworker.personas.manifest import ManifestError, parse_manifest from coworker.server.manager import SessionManager from coworker.teams import Actor, Role, TeamStore from coworker.teams.registry import TeamRegistry, TeamWorker USER = Actor(id="user", role=Role.USER) LEAD = Actor(id="lead-1", role=Role.LEAD) WORKER = Actor(id="swe-worker", role=Role.WORKER) SPACE = "proj" def manifest(team_line=""): return f"""--- id: t name: T family: code tools: [search] {team_line} --- Prompt body. """ # ------------------------------------------------------------------- team trait def test_team_trait_parses_and_gates_capabilities(): lead = parse_manifest(manifest("team: lead")) worker = parse_manifest(manifest("team: worker")) solo = parse_manifest(manifest()) assert lead.team == "lead" and worker.team == "worker" and solo.team is None assert "team:lead" in capability_set(lead) assert "team:worker" in capability_set(worker) assert not any(c.startswith("team:") for c in capability_set(solo)) # the trait reaches the runtime Agent (it gates tool registration) assert lead.to_agent().team == "lead" def test_invalid_team_trait_fails_loudly(): with pytest.raises(ManifestError, match="team"): parse_manifest(manifest("team: manager")) # ------------------------------------------------------- delivery cursors/queue @pytest.fixture def store(tmp_path): store = TeamStore(tmp_path / "teams.db") yield store store.close() def assigned(store, assignee="swe-worker"): item = store.create_item(SPACE, LEAD, title="Task", criteria="tests pass") store.assign(SPACE, LEAD, item["id"], assignee) return item["id"] def test_feed_is_durable_until_consumed(store): assigned(store) first = store.feed_for(SPACE, "swe-worker") # the item's creation and the assignment both start the worker's story assert [e["kind"] for e in first] == ["item_created", "item_assigned"] # not consumed → still pending (crash-safe replay) assert store.feed_for(SPACE, "swe-worker") == first store.consume_feed(SPACE, "swe-worker", first[-1]["seq"]) assert store.feed_for(SPACE, "swe-worker") == [] # a second assignment queues fresh assigned(store) assert len(store.feed_for(SPACE, "swe-worker")) == 2 def test_lead_subscriptions_are_an_allowlist(store): item_id = assigned(store) store.transition(SPACE, WORKER, item_id, "in_progress") # not subscribed store.comment(SPACE, WORKER, item_id, "halfway") # never wakes store.transition(SPACE, WORKER, item_id, "review", comment="done, please check") filed = store.create_item(SPACE, WORKER, title="Found a bug", criteria="fix") subs = store.subscribed_events(SPACE, "lead-1") # Exactly the worker's review transition + the worker's filing: the lead's own # verbs, the in_progress transition, and the comment never wake it. assert all(e["actor"] != "lead-1" for e in subs) assert {(e["kind"], e["payload"].get("to")) for e in subs} == { ("item_transitioned", "review"), ("item_created", None), } store.consume_subscription(SPACE, "lead-1", subs[-1]["seq"]) assert store.subscribed_events(SPACE, "lead-1") == [] _ = filed # --------------------------------------------------------------- registry/budget def test_registry_roundtrip_and_budget_cap(tmp_path): path = tmp_path / "teams.json" reg = TeamRegistry(path) team = reg.create( space=SPACE, lead_session="lead-sid", lead_actor="lead-1", workers=[TeamWorker(actor="swe-worker", persona="swe-worker", session_id="w1")], ) again = TeamRegistry(path) loaded = again.get(team.team_id) assert loaded is not None and loaded.workers[0].session_id == "w1" assert again.for_lead_session("lead-sid").team_id == team.team_id assert again.for_worker_session("w1")[1].actor == "swe-worker" # budget gate: cap wakes, then refuse until the hour rolls assert all(again.count_wake(team.team_id, cap=3) for _ in range(3)) assert again.count_wake(team.team_id, cap=3) is False # -------------------------------------------------------- manager: spawn/digest @pytest.fixture def manager(tmp_path, monkeypatch): monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) ws = tmp_path / "repo" ws.mkdir() m = SessionManager(data_dir=tmp_path / "data", workspace=str(ws)) yield m def test_create_team_fails_closed_on_solo_personas(manager, tmp_path): from coworker.sessions import SessionRecord manager.session_store.save( SessionRecord( session_id="lead-sid", workspace=manager.default_workspace, model="m", mode="interactive", messages=[], agent="cowork", ) ) result = manager.create_team( "lead-sid", [{"persona": "cowork"}] ) # cowork is a solo builtin assert result["approved"] is False assert "team-capable" in result["error"] or "team: worker" in result["error"] assert manager.teams.all() == [] # nothing half-created def test_create_team_prespawns_worker_sessions(manager, monkeypatch): from coworker.agents.base import Agent from coworker.sessions import SessionRecord worker_agent = Agent( name="swe-worker", title="SWE", system_prompt="p", team="worker" ) monkeypatch.setattr( "coworker.server.manager.get_agent", lambda name: worker_agent ) manager.session_store.save( SessionRecord( session_id="lead-sid", workspace=manager.default_workspace, model="m", mode="interactive", messages=[], agent="swe-lead", ) ) result = manager.create_team( "lead-sid", [{"persona": "swe-worker"}, {"persona": "swe-worker", "model": "other"}], ) assert result["approved"] is True actors = [w["actor"] for w in result["workers"]] assert actors == ["swe-worker", "swe-worker-2"] # unique actor ids # pre-spawn = state on disk, zero turns for w in result["workers"]: record = manager.session_store.load(w["session_id"]) assert record is not None assert record.messages == [] assert record.team["role"] == "worker" assert record.team["lead_session"] == "lead-sid" # the lead session is marked and the registry ties the roster lead = manager.session_store.load("lead-sid") assert lead.team["role"] == "lead" assert manager.teams.for_lead_session("lead-sid") is not None # second team on the same session refuses assert manager.create_team("lead-sid", [{"persona": "swe-worker"}])[ "approved" ] is False def test_staleness_digest_is_role_scoped(manager, monkeypatch): from coworker.agents.base import Agent from coworker.sessions import SessionRecord from coworker.teams.model import space_for_workspace # no team role → no digest (bare wake) assert manager.team_staleness_digest("nobody") == "" worker_agent = Agent(name="swe-worker", title="SWE", system_prompt="p", team="worker") monkeypatch.setattr("coworker.server.manager.get_agent", lambda name: worker_agent) manager.session_store.save( SessionRecord( session_id="lead-sid", workspace=manager.default_workspace, model="m", mode="interactive", messages=[], agent="swe-lead", ) ) manager.create_team("lead-sid", [{"persona": "swe-worker"}]) space = space_for_workspace(manager.default_workspace) lead_actor = manager.teams.for_lead_session("lead-sid").lead_actor item = manager.team_store.create_item( space, Actor(id=lead_actor, role=Role.LEAD), title="Ship it", criteria="tests green", ) digest = manager.team_staleness_digest("lead-sid") assert "1 open" in digest assert "no assignee" in digest _ = item def test_team_options_lists_only_enabled_workers(manager): tool = manager._team_options_tool() workers = {w["persona"] for w in tool()["workers"]} assert {"swe-worker", "design-worker", "test-worker"} <= workers # The DevSecOps roster (eighteenth pass) is staffable too. assert {"appsec-worker", "secrets-worker", "posture-worker"} <= workers assert "swe-lead" not in workers # leads staff, they aren't staffed assert "devsecops-lead" not in workers assert "security" not in workers # solo coworkers are not team-eligible assert "cloud-posture" not in workers # its posture-WORKER variant is def test_turn_saves_never_detach_a_worker_from_its_team(manager, monkeypatch): from coworker.agents.base import Agent from coworker.sessions import SessionRecord worker_agent = Agent(name="swe-worker", title="SWE", system_prompt="p", team="worker") monkeypatch.setattr("coworker.server.manager.get_agent", lambda name: worker_agent) manager.session_store.save( SessionRecord( session_id="lead-sid", workspace=manager.default_workspace, model="m", mode="interactive", messages=[], agent="swe-lead", ) ) result = manager.create_team("lead-sid", [{"persona": "swe-worker"}]) wid = result["workers"][0]["session_id"] # A per-turn save rebuilds the record WITHOUT the team field (the engine doesn't # carry it) — owner-hit 2026-08-16: this detached workers from the lead's entry. record = manager.session_store.load(wid) manager.session_store.save( SessionRecord( session_id=wid, workspace=record.workspace, model=record.model, mode=record.mode, messages=[{"role": "user", "content": "hi"}], agent=record.agent, ) ) assert manager.session_store.load(wid).team["lead_session"] == "lead-sid" assert manager.session_store.load("lead-sid").team["role"] == "lead" # ------------------------------------------------------------------- chat (OPE-99) def test_chat_groups_mentions_and_wake_reads(tmp_path): from coworker.teams.chat import ChatStore chat = ChatStore(tmp_path / "chat.db") group = chat.create_group( "team chat", [ {"name": "nia", "persona": "swe-worker", "role": "worker"}, {"name": "webb", "persona": "design-worker", "role": "worker"}, {"name": "lead", "persona": "swe-lead", "role": "lead"}, ], ) gid = group["group_id"] # mention parsing against member handles; unknown handles ignored message = chat.post(gid, "lead", "does the api assume public logos? @nia @nobody") assert message["mentions"] == ["nia"] # mention-only wakes: nia woken, webb not; authors never wake themselves assert [m["seq"] for m in chat.unread_for(gid, "nia")] == [message["seq"]] assert chat.unread_for(gid, "webb") == [] assert chat.unread_for(gid, "lead") == [] chat.consume(gid, "nia", message["seq"]) assert chat.unread_for(gid, "nia") == [] # a USER post wakes every member chat.post(gid, "user", "ship it current-month only", author_role="user") assert len(chat.unread_for(gid, "nia")) == 1 assert len(chat.unread_for(gid, "webb")) == 1 assert len(chat.unread_for(gid, "lead")) == 1 # badge count for the user (its own posts excluded) assert chat.unread_count(gid, "user") == 1 def test_create_team_uses_callnames_and_creates_the_chat_group(manager, monkeypatch): from coworker.agents.base import Agent from coworker.sessions import SessionRecord worker_agent = Agent(name="swe-worker", title="SWE", system_prompt="p", team="worker") monkeypatch.setattr("coworker.server.manager.get_agent", lambda name: worker_agent) manager.session_store.save( SessionRecord( session_id="lead-sid", workspace=manager.default_workspace, model="m", mode="interactive", messages=[], agent="swe-lead", ) ) bad = manager.create_team("lead-sid", [{"persona": "swe-worker", "name": "no spaces!"}]) assert bad["approved"] is False and "callname" in bad["error"] result = manager.create_team( "lead-sid", [ {"persona": "swe-worker", "name": "nia", "reason": "implementation"}, {"persona": "swe-worker", "name": "nia"}, # dupe → suffixed ], enable_chat=True, ) assert [w["actor"] for w in result["workers"]] == ["nia", "nia-2"] team = manager.teams.for_lead_session("lead-sid") assert team.chat_enabled and team.chat_group group = manager.chat_store.get_group(team.chat_group) assert {m["name"] for m in group["members"]} == {"nia", "nia-2", "lead"} # the worker digest carries the roster + how to reach teammates digest, _rows = manager._team_digest(team, [], [], is_lead=False) assert "Your team: nia (swe-worker — implementation)" in digest assert "@name in # team chat" in digest def test_digest_clamps_long_comments_and_carries_structured_rows(manager): """Hand-off essays live on the board; the wake message carries a head, the sidecar carries UI rows (owner ruling 2026-08-16 — the digest was arriving as a wall of text).""" from coworker.teams.registry import Team space = str(manager.default_workspace) lead = Actor(id="lead-1", role=Role.LEAD) worker = Actor(id="nia", role=Role.WORKER) item = manager.team_store.create_item( space, lead, title="Big item", criteria="c" ) manager.team_store.assign(space, lead, item["id"], "nia") essay = "verified the endpoint thoroughly. " * 40 # ~1300 chars manager.team_store.transition( space, worker, item["id"], "in_progress" ) manager.team_store.transition( space, worker, item["id"], "review", comment=essay ) team = Team(team_id="t1", space=space, lead_session="s", lead_actor="lead-1") subs = manager.team_store.subscribed_events(space, "lead-1") message, rows = manager._team_digest(team, [], subs, is_lead=True) # model text: clamped hard, with the pointer back to the board assert essay not in message assert "(full text on the board)" in message assert len(message) < 1200 # sidecar rows: structured, softer clamp for the human on click moved = [r for r in rows if r["kind"] == "moved" and r["to"] == "review"] assert moved and moved[0]["item"] == item["id"] assert moved[0]["note"].endswith("…") and len(moved[0]["note"]) < 700 def test_item_detail_timeline_and_blocker_fact(manager): """The detail pane renders the item's merged event story; blocked rows carry the latest blocker comment as a plain fact (owner-approved mock 2026-08-17).""" space = str(manager.default_workspace) lead = Actor(id="lead-1", role=Role.LEAD) worker = Actor(id="nia", role=Role.WORKER) item = manager.team_store.create_item(space, lead, title="Story", criteria="c") manager.team_store.assign(space, lead, item["id"], "nia") manager.team_store.transition(space, worker, item["id"], "in_progress") manager.team_store.comment(space, worker, item["id"], "halfway there") manager.team_store.transition( space, worker, item["id"], "blocked", comment="need the staging tfvars" ) # session with this workspace → the board space resolves from coworker.sessions import SessionRecord manager.session_store.save( SessionRecord( session_id="sid", workspace=space, model="m", mode="interactive", messages=[], agent="cowork", ) ) detail = manager.board_item_detail("sid", item["id"]) kinds = [(e["kind"], e.get("to")) for e in detail["timeline"]] assert kinds == [ ("created", None), ("assigned", None), ("moved", "in_progress"), ("comment", None), ("moved", "blocked"), ] assert detail["timeline"][3]["body"] == "halfway there" board = manager.session_board("sid") blocked = next(i for i in board["items"] if i["id"] == item["id"]) assert blocked["blocker"] == "need the staging tfvars" def test_session_attachment_read_is_scoped_to_its_board(manager): from coworker.sessions import SessionRecord from coworker.teams import BoardError from coworker.teams.attachments import stored_name space = str(manager.default_workspace) manager.session_store.save( SessionRecord( session_id="sid", workspace=space, model="m", mode="interactive", messages=[], agent="cowork", ) ) other = manager.team_store.create_item( "other-space", LEAD, title="Other board", criteria="c" ) ref = manager.attachment_store.put( b"\x89PNG\r\n\x1a\nprivate", "private.png" ) manager.team_store.attach_ref( "other-space", LEAD, other["id"], "private", ref ) with pytest.raises(BoardError, match="attachment not found"): manager.board_attachment("sid", stored_name(ref)) def test_session_attachment_route_uses_the_session_board(manager, monkeypatch): from fastapi.testclient import TestClient from coworker.server.app import create_app from coworker.sessions import SessionRecord from coworker.teams.attachments import stored_name monkeypatch.delenv("COWORKER_API_TOKEN", raising=False) space = str(manager.default_workspace) manager.session_store.save( SessionRecord( session_id="sid", workspace=space, model="m", mode="interactive", messages=[], agent="cowork", ) ) other = manager.team_store.create_item( "other-space", LEAD, title="Other board", criteria="c" ) ref = manager.attachment_store.put( b"\x89PNG\r\n\x1a\nprivate", "private.png" ) manager.team_store.attach_ref( "other-space", LEAD, other["id"], "private", ref ) client = TestClient(create_app(manager)) response = client.get( "/v1/sessions/sid/board/attachment", params={"name": stored_name(ref)}, ) assert response.status_code == 404 assert response.json() == {"error": "attachment not found"} item = manager.team_store.create_item( space, LEAD, title="This board", criteria="c" ) visible = manager.attachment_store.put( b"\x89PNG\r\n\x1a\nvisible", "visible.png" ) manager.team_store.attach_ref(space, LEAD, item["id"], "visible", visible) allowed = client.get( "/v1/sessions/sid/board/attachment", params={"name": stored_name(visible)}, ) client.close() assert allowed.status_code == 200 assert allowed.content == b"\x89PNG\r\n\x1a\nvisible" assert allowed.headers["content-type"] == "image/png" def test_feed_interest_follows_the_assignment_relation(store): """Owner ruling 2026-08-17: no per-event addressing — a worker is subscribed to everything on its slice. Send-backs, comment ANSWERS (the silently broken path), and acceptance all arrive through one relation.""" item_id = assigned(store) store.consume_feed(SPACE, "swe-worker", store.feed_for(SPACE, "swe-worker")[-1]["seq"]) store.transition(SPACE, WORKER, item_id, "in_progress") store.transition(SPACE, WORKER, item_id, "review", comment="ready") assert store.feed_for(SPACE, "swe-worker") == [] # own moves never self-deliver # the lead's send-back arrives with the feedback… store.transition(SPACE, LEAD, item_id, "in_progress", comment="totals drift") # …and so does a lead's comment ANSWER (never delivered under addressing) store.comment(SPACE, LEAD, item_id, "use the v2 endpoint") feed = store.feed_for(SPACE, "swe-worker") assert [e["kind"] for e in feed] == ["item_transitioned", "item_commented"] assert feed[0]["payload"]["comment"] == "totals drift" assert feed[1]["payload"]["body"] == "use the v2 endpoint" store.consume_feed(SPACE, "swe-worker", feed[-1]["seq"]) # acceptance is in-slice too: the worker hears closure (and can pick up next) store.transition(SPACE, WORKER, item_id, "review", comment="fixed") store.transition(SPACE, LEAD, item_id, "done") assert [e["payload"]["to"] for e in store.feed_for(SPACE, "swe-worker")] == ["done"] def test_feed_reassignment_delivers_then_interest_ends(store): item_id = assigned(store) store.consume_feed(SPACE, "swe-worker", store.feed_for(SPACE, "swe-worker")[-1]["seq"]) store.assign(SPACE, LEAD, item_id, "other-worker") # the loser hears the reassignment… feed = store.feed_for(SPACE, "swe-worker") assert [e["kind"] for e in feed] == ["item_assigned"] assert feed[0]["payload"]["previous"] == "swe-worker" store.consume_feed(SPACE, "swe-worker", feed[-1]["seq"]) # …then goes quiet: later events on the item never reach it store.comment(SPACE, LEAD, item_id, "carry on") assert store.feed_for(SPACE, "swe-worker") == [] # while the new holder gets the item's whole story (its slice now) — the # history it needs to pick the work up kinds = [e["kind"] for e in store.feed_for(SPACE, "other-worker")] assert kinds == [ "item_created", "item_assigned", "item_assigned", "item_commented", ] def test_cancel_reaches_the_assignee_through_the_feed(store): item_id = assigned(store) store.consume_feed(SPACE, "swe-worker", store.feed_for(SPACE, "swe-worker")[-1]["seq"]) store.transition(SPACE, LEAD, item_id, "canceled", comment="scope cut") pending = store.feed_for(SPACE, "swe-worker") assert len(pending) == 1 assert pending[0]["kind"] == "item_transitioned" assert pending[0]["payload"]["to"] == "canceled" def test_lead_backstop_fires_only_for_forgotten_timers(manager, monkeypatch): import time as _time from coworker.agents.base import Agent from coworker.sessions import SessionRecord from coworker.teams.model import space_for_workspace worker_agent = Agent(name="swe-worker", title="SWE", system_prompt="p", team="worker") monkeypatch.setattr("coworker.server.manager.get_agent", lambda name: worker_agent) manager.session_store.save( SessionRecord( session_id="lead-sid", workspace=manager.default_workspace, model="m", mode="interactive", messages=[], agent="swe-lead", ) ) manager.create_team("lead-sid", [{"persona": "swe-worker", "name": "nia"}]) team = manager.teams.for_lead_session("lead-sid") space = space_for_workspace(manager.default_workspace) lead = Actor(id=team.lead_actor, role=Role.LEAD) # Only an OPEN item → no backstop (nothing is in flight). item = manager.team_store.create_item(space, lead, title="T", criteria="c") manager._team_last_alive["lead-sid"] = _time.time() - 700 assert manager._lead_backstop_due(team) is False # Active item + stale clock + no timer → due. manager.team_store.assign(space, lead, item["id"], "nia") worker = Actor(id="nia", role=Role.WORKER) manager.team_store.transition(space, worker, item["id"], "in_progress") manager._team_last_alive["lead-sid"] = _time.time() - 700 assert manager._lead_backstop_due(team) is True # A pending self-wake timer means the lead owns its cadence → never backstop. manager.wakes.add_timer("lead-sid", __import__("datetime").datetime.now( __import__("datetime").timezone.utc )) assert manager._lead_backstop_due(team) is False