diff --git a/coworker/personas/builtin/design-worker/manifest.md b/coworker/personas/builtin/design-worker/manifest.md index df084fab..12c6dab8 100644 --- a/coworker/personas/builtin/design-worker/manifest.md +++ b/coworker/personas/builtin/design-worker/manifest.md @@ -12,7 +12,7 @@ default_permission_mode: interactive description: A UI/UX-focused coworker that works team-style under a lead — layout, styling, interaction polish, and design-system consistency, handed off through review. --- You are a UI/UX engineer working ON A TEAM under a lead coworker. Your interlocutor is -the LEAD, not the end user — no ask_user; questions become item comments. +the LEAD, not the end user — no ask_user; questions become item comments (or @lead via post_chat when # team chat is enabled). The team contract (this is how you work): - Your task arrives as a WORK ITEM: description = assignment, acceptance criteria = diff --git a/coworker/personas/builtin/swe-lead/manifest.md b/coworker/personas/builtin/swe-lead/manifest.md index 7e2e35a2..60bcbbe7 100644 --- a/coworker/personas/builtin/swe-lead/manifest.md +++ b/coworker/personas/builtin/swe-lead/manifest.md @@ -24,9 +24,13 @@ How you run a piece of work: propose_work_items (works in any mode; approval creates the items on the board and returns their ids) and revise until the user approves. Use create_item only for one-off additions after the plan is approved. -3. STAFF: propose the workers you need with propose_team ({persona, model, reason} per - member). Approval creates their sessions and returns actor ids. Only team-capable - worker coworkers can be staffed. +3. STAFF: propose the workers you need with propose_team ({persona, name, model, + reason} per member). Give each a short callname (e.g. "nia", "webb", "checks") — + it becomes their handle for assignment and @mentions, and lets you staff two of + the same coworker. Approval creates their sessions and returns the handles. Only + team-capable worker coworkers can be staffed (team_options lists them). When you + assign work, teammates' names are shared automatically — add the context that + isn't: who owns what interface, who to ask about which decision. 4. ASSIGN: assign items to actor ids. The item IS the worker's assignment — its description and criteria must stand alone. Respect dependencies (link blocks/parent); don't assign what's blocked. diff --git a/coworker/personas/builtin/swe-worker/manifest.md b/coworker/personas/builtin/swe-worker/manifest.md index 627b848f..6acb8a0a 100644 --- a/coworker/personas/builtin/swe-worker/manifest.md +++ b/coworker/personas/builtin/swe-worker/manifest.md @@ -12,7 +12,7 @@ default_permission_mode: interactive description: A software engineer coworker that works team-style — it takes assigned work items from a lead coworker, implements them against their acceptance criteria, and hands off through review. --- You are a software engineer working ON A TEAM under a lead coworker. Your interlocutor -is the LEAD, not the end user — you never use ask_user; questions become item comments, +is the LEAD, not the end user — you never use ask_user; questions become item comments (or @lead via post_chat when # team chat is enabled), and you keep working on what isn't blocked by the answer. The team contract (this is how you work): diff --git a/coworker/personas/builtin/test-worker/manifest.md b/coworker/personas/builtin/test-worker/manifest.md index 827616bf..9935ea86 100644 --- a/coworker/personas/builtin/test-worker/manifest.md +++ b/coworker/personas/builtin/test-worker/manifest.md @@ -14,7 +14,7 @@ description: A verification coworker for teams — it independently tests what a You are the team's verifier. A builder coworker finished an item; the lead assigned you a linked verification item. Your job: independently establish whether the work MEETS ITS ACCEPTANCE CRITERIA — assume it doesn't until the evidence says otherwise. Your -interlocutor is the LEAD, not the end user — no ask_user; questions become item comments. +interlocutor is the LEAD, not the end user — no ask_user; questions become item comments (or @lead via post_chat when # team chat is enabled). How you verify: - Start from the item under verification: its criteria are your checklist, one by one. diff --git a/coworker/server/app.py b/coworker/server/app.py index aca7c588..b8ffd24e 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -750,6 +750,14 @@ def create_app(manager: SessionManager) -> FastAPI: comment=str(body.get("comment", "")), ) + @app.get("/v1/teams/{team_id}/chat") + def team_chat(team_id: str) -> dict[str, Any]: + return manager.team_chat(team_id) + + @app.post("/v1/teams/{team_id}/chat") + def team_chat_post(team_id: str, body: dict) -> dict[str, Any]: + return manager.post_team_chat(team_id, str((body or {}).get("text", ""))) + @app.get("/v1/teams/journal") def teams_journal() -> dict[str, Any]: return {"cases": manager.journal_overview()} @@ -1866,10 +1874,17 @@ def create_app(manager: SessionManager) -> FastAPI: "approved": False, "feedback": resp.get("feedback") or "the user declined this roster", } + # The gate checkbox is the USER's call: an explicit enable_chat in the + # response overrides whatever the lead proposed. + enable_chat = bool( + resp["enable_chat"] + if "enable_chat" in resp + else _args.get("enable_chat", False) + ) return manager.create_team( session_id, [m for m in members if isinstance(m, dict)], - enable_chat=bool(_args.get("enable_chat", False)), + enable_chat=enable_chat, ) async def items_approver(_args: dict, tool_call_id=None) -> dict: @@ -2100,6 +2115,11 @@ def create_app(manager: SessionManager) -> FastAPI: { "approved": bool(message.get("approved")), "feedback": message.get("feedback", ""), + **( + {"enable_chat": bool(message.get("enable_chat"))} + if "enable_chat" in message + else {} + ), } ) ) diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 42889b91..4060b356 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -87,6 +87,7 @@ from ..teams import Actor as TeamActor from ..teams import BoardError as TeamsBoardError from ..teams import JournalStore, Role as TeamRole, TeamStore, board_tools, journal_tools from ..teams.model import space_for_workspace +from ..teams.chat import ChatStore from ..teams.registry import TeamRegistry, TeamWorker from ..skills import ( SessionSkillStore, @@ -209,6 +210,7 @@ class SessionManager: # sessions per board) that the wake plumbing walks. self.journal_store = JournalStore(base / "journal.db") self.team_store = TeamStore(base / "teams.db", journal=self.journal_store) + self.chat_store = ChatStore(base / "chat.db") self.teams = TeamRegistry(base / "teams.json") self._team_inflight: set[str] = set() self._loop: Optional[asyncio.AbstractEventLoop] = None @@ -1467,6 +1469,38 @@ class SessionManager: "note": "items created on the board — staff and assign to start work", } + def team_chat(self, team_id: str, *, mark_read: bool = True) -> dict[str, Any]: + """The chat view's payload. Viewing IS reading for the user: the badge + cursor advances on fetch.""" + team = self.teams.get(team_id) + if team is None or not team.chat_enabled or not team.chat_group: + return {"enabled": False, "messages": [], "members": []} + group = self.chat_store.get_group(team.chat_group) or {"members": []} + messages = self.chat_store.messages(team.chat_group) + if mark_read and messages: + self.chat_store.consume(team.chat_group, "user", messages[-1]["seq"]) + return { + "enabled": True, + "team_id": team_id, + "members": group["members"], + "messages": messages, + } + + def post_team_chat(self, team_id: str, text: str) -> dict[str, Any]: + team = self.teams.get(team_id) + if team is None or not team.chat_enabled or not team.chat_group: + return {"error": "chat is not enabled for this team"} + try: + message = self.chat_store.post( + team.chat_group, "user", text, author_role="user" + ) + except (TeamsBoardError, ValueError) as error: + return {"error": str(error)} + # A user post wakes every member — kick the drain rather than waiting a tick. + if self._loop is not None: + asyncio.run_coroutine_threadsafe(self.team_tick(), self._loop) + return message + def journal_overview(self) -> list[dict[str, Any]]: return self.journal_store.overview(self._user_actor()) @@ -1507,8 +1541,82 @@ class SessionManager: if role == "lead": tools.append(self._steer_tool(session_id)) tools.append(self._team_options_tool()) + # post_chat registers for every team persona; it resolves the group at call + # time (the team may not exist yet at engine build) and fails gracefully + # when chat is off. + tools.append(self._post_chat_tool(session_id, role)) return tools + def _post_chat_tool(self, session_id: str, role: str) -> Any: + import aisuite as ai + + manager = self + + def post_chat(text: str, record_on_item: Optional[int] = None) -> dict: + """Post to # team chat. Mention teammates with @name to reach them — + only mentioned members are woken (the user always sees it). Chat is for + questions and consensus; status lives on the board. If your message + answers something that matters, pass record_on_item to also record it + as a comment on that work item.""" + team, handle, actor = manager._chat_identity(session_id, role) + if team is None: + return {"error": "this session is not part of a team"} + if not team.chat_enabled or not team.chat_group: + return {"error": "team chat is not enabled for this team"} + try: + message = manager.chat_store.post( + team.chat_group, handle, text, author_role=role + ) + except (TeamsBoardError, ValueError) as error: + return {"error": str(error)} + result: dict[str, Any] = { + "ok": True, + "mentioned": message["mentions"], + } + if record_on_item is not None and actor is not None: + try: + manager.team_store.comment( + team.space, actor, int(record_on_item), text + ) + result["recorded_on"] = int(record_on_item) + except (TeamsBoardError, ValueError) as error: + result["record_error"] = str(error) + return result + + return ai.tool( + post_chat, + metadata=ai.ToolMetadata( + category="team", risk_level="low", capabilities=["team"] + ), + ) + + def _chat_identity(self, session_id: str, role: str): + """(team, chat handle, board actor) for a team session — the lead's chat + handle is "lead"; a worker's handle IS its board actor (the callname).""" + if role == "lead": + team = self.teams.for_lead_session(session_id) + if team is None: + return None, "", None + record = self.session_store.load(session_id) + actor = TeamActor( + id=team.lead_actor, + role=TeamRole.LEAD, + persona=record.agent if record else "", + session_id=session_id, + ) + return team, "lead", actor + found = self.teams.for_worker_session(session_id) + if found is None: + return None, "", None + team, worker = found + actor = TeamActor( + id=worker.actor, + role=TeamRole.WORKER, + persona=worker.persona, + session_id=session_id, + ) + return team, worker.actor, actor + def _team_options_tool(self) -> Any: """Registry-injected staffing knowledge: the lead's options come from the persona registry at call time — installing a worker coworker automatically @@ -1599,7 +1707,7 @@ class SessionManager: return {"approved": False, "error": "this session already leads a team"} space = space_for_workspace(record.workspace) workers: list[TeamWorker] = [] - used: set[str] = set() + used: set[str] = {"lead", "user", "board"} # reserved handles for member in members: pid = str((member or {}).get("persona", "")).strip() try: @@ -1616,9 +1724,17 @@ class SessionManager: "approved": False, "error": f"'{pid}' is not team-capable (needs `team: worker`)", } - actor, n = pid, 2 + # The lead-given callname is the HANDLE: board assignee, @mention target, + # sidebar label. It must be mention-safe and unique on the team. + name = str(member.get("name", "")).strip().lower() + if name and not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,23}", name): + return { + "approved": False, + "error": f"'{name}' isn't a usable callname — letters/digits/._- only, max 24", + } + actor, n = name or pid, 2 while actor in used: - actor, n = f"{pid}-{n}", n + 1 + actor, n = f"{name or pid}-{n}", n + 1 used.add(actor) worker_sid = uuid.uuid4().hex[:12] model = str(member.get("model") or record.model) @@ -1645,14 +1761,34 @@ class SessionManager: }, ) workers.append( - TeamWorker(actor=actor, persona=pid, session_id=worker_sid, model=model) + TeamWorker( + actor=actor, + persona=pid, + session_id=worker_sid, + model=model, + reason=str(member.get("reason", "")).strip(), + ) ) + chat_group = "" + if enable_chat: + group = self.chat_store.create_group( + "team chat", + [ + *( + {"name": w.actor, "persona": w.persona, "role": "worker"} + for w in workers + ), + {"name": "lead", "persona": record.agent, "role": "lead"}, + ], + ) + chat_group = group["group_id"] team = self.teams.create( space=space, lead_session=session_id, lead_actor=f"{record.agent}:{session_id[:8]}", workers=workers, chat_enabled=enable_chat, + chat_group=chat_group, ) for worker in workers: self.session_store.set_team( @@ -1719,14 +1855,32 @@ class SessionManager: subs = ( self.team_store.subscribed_events(team.space, actor) if is_lead else [] ) - if not directs and not subs: + chat_handle = "lead" if is_lead else actor + chats = ( + self.chat_store.unread_for(team.chat_group, chat_handle) + if team.chat_enabled and team.chat_group + else [] + ) + # Cancel is top-priority: an in-flight worker gets interrupted NOW; the + # queued notice (delivered when the turn dies) tells it why. + cancels = [ + e + for e in directs + if e["kind"] == "item_transitioned" + and (e.get("payload") or {}).get("to") == "canceled" + ] + if cancels and self.is_running(session_id): + engine = self._engines.get(session_id) + if engine is not None: + engine.request_interrupt() + if not directs and not subs and not chats: return 0 if self.is_running(session_id) or session_id in self._team_inflight: return 0 # it will drain on its next turn end / next tick if not self.teams.count_wake(team.team_id, cap=self.TEAM_WAKE_CAP_PER_HOUR): logger.warning("team %s paused for budget this hour", team.team_id) return 0 - message = self._team_digest(team, directs, subs, is_lead=is_lead) + message = self._team_digest(team, directs, subs, chats, is_lead=is_lead) self._team_inflight.add(session_id) source = self._board_source(team, message) @@ -1741,6 +1895,10 @@ class SessionManager: self.team_store.consume_subscription( team.space, actor, subs[-1]["seq"] ) + if chats: + self.chat_store.consume( + team.chat_group, chat_handle, chats[-1]["seq"] + ) finally: self._team_inflight.discard(session_id) @@ -1748,7 +1906,13 @@ class SessionManager: return 1 def _team_digest( - self, team, directs: list[dict], subs: list[dict], *, is_lead: bool + self, + team, + directs: list[dict], + subs: list[dict], + chats: Optional[list[dict]] = None, + *, + is_lead: bool, ) -> str: """Coalesce one queue batch into one wake message. Deterministic, computed by code — the model does judgment, not arithmetic.""" @@ -1774,13 +1938,22 @@ class SessionManager: elif event["kind"] == "item_transitioned": to = payload.get("to", "?") note = f" — “{payload.get('comment')}”" if payload.get("comment") else "" - lines.append(f"{title} moved to {to} by {event['actor']}{note}") + if to == "canceled" and not is_lead: + lines.append( + f"{title} was CANCELED by {event['actor']}{note} — stop any" + " work on it and pick up your other assignments." + ) + else: + lines.append(f"{title} moved to {to} by {event['actor']}{note}") elif event["kind"] == "item_created": lines.append(f"New item filed by {event['actor']}: {title}") elif event["kind"] == "item_commented": lines.append( f"Comment on {title} by {event['actor']}: {payload.get('body', '')}" ) + for chat in chats or []: + who = chat["author"] if chat["author_role"] != "user" else "[User]" + lines.append(f"# team chat — {who}: {chat['text']}") body = "\n".join(f"- {line}" for line in lines) or "- (no detail)" if is_lead: return ( @@ -1793,11 +1966,30 @@ class SessionManager: return ( "[Lead] Board update:\n" + body + + self._roster_note(team) + "\n\nMove your item to in_progress when you start; blocked (with a" " comment) if stuck; review with a hand-off comment when finished." " Journal evidence as you go." ) + @staticmethod + def _roster_note(team) -> str: + """Teammate awareness as a mechanism: every worker digest carries the + roster, so tagging teammates never depends on the lead remembering to + introduce them.""" + if not team.workers: + return "" + mates = "; ".join( + f"{w.actor} ({w.persona}" + (f" — {w.reason})" if w.reason else ")") + for w in team.workers + ) + reach = ( + " Reach them or the lead with @name in # team chat (post_chat)." + if team.chat_enabled + else " Coordinate through item comments; the lead reads the board." + ) + return f"\n\nYour team: {mates}; lead (coordinator).{reach}" + @staticmethod def _board_source(team, message: str) -> dict[str, Any]: """Display-only MessageSource sidecar for board deliveries — the same @@ -4464,6 +4656,13 @@ class SessionManager: "team_id": info.get("team_id", ""), "lead_session": info.get("lead_session", ""), } + if info.get("role") == "lead": + team = self.teams.get(str(info.get("team_id", ""))) + if team is not None and team.chat_enabled and team.chat_group: + row["chat_enabled"] = True + row["chat_unread"] = self.chat_store.unread_count( + team.chat_group, "user" + ) if info.get("role") == "worker" and info.get("space") and info.get("actor"): try: items = self.team_store.list_items( diff --git a/coworker/teams/chat.py b/coworker/teams/chat.py new file mode 100644 index 00000000..0c885c1c --- /dev/null +++ b/coworker/teams/chat.py @@ -0,0 +1,215 @@ +"""The chat store — group chat as its own abstraction (eighth pass, 2026-08-16). + +A GROUP is `{group_id, name, members[]}` plus an append-only message log and +per-member unread cursors. One group per team in v1 (created at the staffing gate +when chat is enabled), but nothing here knows about boards or teams — groups can +later serve non-team chats and the external-chat dialect. + +Wake semantics live in the read side: an agent post is "for" exactly its @mentioned +members; a USER post is for every member ([User] outranks — posting to the channel +is rare and deliberate). Un-mentioned agent chatter wakes nobody, which is what +keeps chat an exception channel structurally. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 +import threading +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from .model import BoardError + + +class ChatStore: + def __init__(self, db_path: str | Path) -> None: + self.db_path = str(db_path) + if self.db_path != ":memory:": + Path(self.db_path).expanduser().parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.RLock() + self._conn = sqlite3.connect(self.db_path, check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._conn.executescript(""" + CREATE TABLE IF NOT EXISTS chat_groups ( + group_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + members TEXT NOT NULL, + created_ts TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS chat_messages ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + group_id TEXT NOT NULL, + ts TEXT NOT NULL, + author TEXT NOT NULL, + author_role TEXT NOT NULL, + text TEXT NOT NULL, + mentions TEXT NOT NULL DEFAULT '[]' + ); + CREATE INDEX IF NOT EXISTS idx_chat_group ON chat_messages (group_id, seq); + CREATE TABLE IF NOT EXISTS chat_cursors ( + cursor_key TEXT PRIMARY KEY, + read_seq INTEGER NOT NULL + ); + """) + self._conn.commit() + + # ---------------------------------------------------------------------- groups + + def create_group(self, name: str, members: list[dict[str, Any]]) -> dict[str, Any]: + """`members`: [{name, persona, role}] — `name` is the member's handle + (@mention target). The user participates implicitly and is not a member row.""" + handles = [str(m.get("name", "")).strip() for m in members] + if not name.strip(): + raise BoardError("group name is required") + if not all(handles) or len(set(handles)) != len(handles): + raise BoardError("every member needs a unique name") + group = { + "group_id": uuid.uuid4().hex[:12], + "name": name.strip(), + "members": [ + { + "name": str(m.get("name")), + "persona": str(m.get("persona", "")), + "role": str(m.get("role", "worker")), + } + for m in members + ], + "created_ts": datetime.now(timezone.utc).isoformat(), + } + with self._lock: + self._conn.execute( + "INSERT INTO chat_groups (group_id, name, members, created_ts)" + " VALUES (?, ?, ?, ?)", + ( + group["group_id"], + group["name"], + json.dumps(group["members"]), + group["created_ts"], + ), + ) + self._conn.commit() + return group + + def get_group(self, group_id: str) -> Optional[dict[str, Any]]: + with self._lock: + row = self._conn.execute( + "SELECT * FROM chat_groups WHERE group_id = ?", (group_id,) + ).fetchone() + if row is None: + return None + group = dict(row) + group["members"] = json.loads(group.pop("members") or "[]") + return group + + # -------------------------------------------------------------------- messages + + def post( + self, group_id: str, author: str, text: str, *, author_role: str = "worker" + ) -> dict[str, Any]: + """Append one message. Mentions are parsed against member handles — + `@name` anywhere in the text — so tagging needs no separate parameter.""" + group = self.get_group(group_id) + if group is None: + raise BoardError(f"no chat group '{group_id}'") + if not (text or "").strip(): + raise BoardError("message text is required") + handles = {m["name"] for m in group["members"]} + mentions = sorted( + { + m.group(1) + for m in re.finditer(r"@([\w.-]+)", text) + if m.group(1) in handles + } + ) + message = { + "group_id": group_id, + "ts": datetime.now(timezone.utc).isoformat(), + "author": author, + "author_role": author_role, + "text": text, + "mentions": mentions, + } + with self._lock: + cursor = self._conn.execute( + "INSERT INTO chat_messages" + " (group_id, ts, author, author_role, text, mentions)" + " VALUES (?, ?, ?, ?, ?, ?)", + ( + group_id, + message["ts"], + author, + author_role, + text, + json.dumps(mentions), + ), + ) + self._conn.commit() + return {**message, "seq": cursor.lastrowid} + + def messages( + self, group_id: str, *, since_seq: int = 0, limit: int = 200 + ) -> list[dict[str, Any]]: + with self._lock: + rows = self._conn.execute( + "SELECT * FROM chat_messages WHERE group_id = ? AND seq > ?" + " ORDER BY seq LIMIT ?", + (group_id, since_seq, max(1, min(int(limit or 200), 2000))), + ).fetchall() + return [_row_to_message(row) for row in rows] + + # ------------------------------------------------------- unread / wake reads + + def unread_for(self, group_id: str, member: str) -> list[dict[str, Any]]: + """Messages this member should be WOKEN for: posts that @mention it, plus + every user post. Its own posts never count.""" + out = [] + for message in self.messages(group_id, since_seq=self._cursor(group_id, member)): + if message["author"] == member: + continue + if member in message["mentions"] or message["author_role"] == "user": + out.append(message) + return out + + def unread_count(self, group_id: str, member: str) -> int: + """Plain unread count (all messages since the member's cursor) — drives the + sidebar badge for the USER, whose 'member' key is "user".""" + with self._lock: + row = self._conn.execute( + "SELECT COUNT(*) AS n FROM chat_messages WHERE group_id = ?" + " AND seq > ? AND author != ?", + (group_id, self._cursor(group_id, member), member), + ).fetchone() + return int(row["n"]) + + def consume(self, group_id: str, member: str, upto_seq: int) -> None: + with self._lock: + self._conn.execute( + "INSERT INTO chat_cursors (cursor_key, read_seq) VALUES (?, ?)" + " ON CONFLICT(cursor_key) DO UPDATE SET read_seq =" + " MAX(read_seq, ?)", + (f"{group_id}:{member}", int(upto_seq), int(upto_seq)), + ) + self._conn.commit() + + def close(self) -> None: + self._conn.close() + + def _cursor(self, group_id: str, member: str) -> int: + row = self._conn.execute( + "SELECT read_seq FROM chat_cursors WHERE cursor_key = ?", + (f"{group_id}:{member}",), + ).fetchone() + return int(row["read_seq"]) if row else 0 + + +def _row_to_message(row: sqlite3.Row) -> dict[str, Any]: + message = dict(row) + try: + message["mentions"] = json.loads(message.get("mentions") or "[]") + except json.JSONDecodeError: + message["mentions"] = [] + return message diff --git a/coworker/teams/registry.py b/coworker/teams/registry.py index 53d7a855..ce522c69 100644 --- a/coworker/teams/registry.py +++ b/coworker/teams/registry.py @@ -20,10 +20,11 @@ from typing import Optional @dataclass class TeamWorker: - actor: str # board actor id (stable; assignments address this) + actor: str # the lead-given NAME — board actor id, assignee handle, @mention target persona: str session_id: str model: str = "" + reason: str = "" # why the lead staffed it — surfaces in teammates' rosters @dataclass @@ -34,6 +35,7 @@ class Team: lead_actor: str workers: list[TeamWorker] = field(default_factory=list) chat_enabled: bool = False + chat_group: str = "" # ChatStore group_id when chat is enabled paused: bool = False # budget/user pause: the wake gate skips a paused team created_at: str = field( default_factory=lambda: datetime.now(timezone.utc).isoformat() @@ -76,6 +78,7 @@ class TeamRegistry: lead_actor: str, workers: list[TeamWorker], chat_enabled: bool = False, + chat_group: str = "", ) -> Team: team = Team( team_id=uuid.uuid4().hex[:12], @@ -84,6 +87,7 @@ class TeamRegistry: lead_actor=lead_actor, workers=workers, chat_enabled=chat_enabled, + chat_group=chat_group, ) with self._lock: self._teams[team.team_id] = team diff --git a/coworker/teams/store.py b/coworker/teams/store.py index 4599c02e..81871eb4 100644 --- a/coworker/teams/store.py +++ b/coworker/teams/store.py @@ -517,12 +517,23 @@ class TeamStore: f"illegal transition {current.value} → {target.value}" ) self._check_transition_authority(actor, item, current, target) + # Canceling an assigned item ADDRESSES the notice to its assignee — the + # top-priority queue entry whose delivery (or in-flight interrupt) makes + # the worker actually stop, instead of finishing into the void. + recipient = ( + item["assignee"] + if target is ItemState.CANCELED + and item["assignee"] + and item["assignee"] != actor.id + else None + ) event = self.append_event( space, ITEM_TRANSITIONED, actor, item_id=item_id, case_id=item["case_id"] or None, + recipient=recipient, payload={ "from": current.value, "to": target.value, diff --git a/coworker/teams/tools.py b/coworker/teams/tools.py index 39bbc142..16054b80 100644 --- a/coworker/teams/tools.py +++ b/coworker/teams/tools.py @@ -227,9 +227,11 @@ _PROPOSE_TEAM_SCHEMA = { "function": { "name": "propose_team", "description": ( - "Propose the worker coworkers you need for this board. The user sees the" - " roster and approves it; approval creates the worker sessions and" - " returns their actor ids so you can assign work items to them. Only" + "Propose the worker coworkers you need for this board. Give EACH member" + " a short unique callname (`name`, e.g. 'nia', 'webb', 'checks') — it" + " becomes their handle for assignment and @mentions, and lets you staff" + " two of the same coworker. The user sees the roster and approves it;" + " approval creates the worker sessions and returns the handles. Only" " team-capable worker coworkers may be proposed." ), "parameters": { @@ -241,10 +243,11 @@ _PROPOSE_TEAM_SCHEMA = { "type": "object", "properties": { "persona": {"type": "string"}, + "name": {"type": "string"}, "model": {"type": "string"}, "reason": {"type": "string"}, }, - "required": ["persona"], + "required": ["persona", "name"], }, }, "enable_chat": {"type": "boolean"}, diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 9d58a0fb..fbe67a3d 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -565,6 +565,17 @@ export async function mockApi(page: import("@playwright/test").Page) { // "plan the work" (the fake agent then files items; no draft state — the board only // holds accepted work). Mutable so transitions round-trip through the real endpoints. const boardItems: any[] = []; + // # team chat log — seeded with one lead question so mention highlighting renders. + const chatMessages: any[] = [ + { + seq: 1, + ts: new Date().toISOString(), + author: "lead", + author_role: "lead", + text: "@nia does the api assume the assets bucket is public? quick check before you write it up.", + mentions: ["nia"], + }, + ]; const seedBoard = () => { if (boardItems.length) return; boardItems.push( @@ -648,12 +659,12 @@ export async function mockApi(page: import("@playwright/test").Page) { if (/staff the team/i.test(msg.text)) { send("team_proposed", { members: [ - { persona: "swe-worker", model: "anthropic:claude-opus-4-8", reason: "implementation" }, - { persona: "design-worker", reason: "UI polish" }, - { persona: "test-worker", reason: "verifies against acceptance criteria" }, + { persona: "swe-worker", name: "nia", model: "anthropic:claude-opus-4-8", reason: "implementation" }, + { persona: "design-worker", name: "webb", reason: "UI polish" }, + { persona: "test-worker", name: "checks", reason: "verifies against acceptance criteria" }, ], enable_chat: false, - note: "Three workers cover the plan; test-worker verifies before anything closes.", + note: "Three workers cover the plan; checks verifies before anything closes.", }); return; // suspended on the staffing decision } @@ -883,16 +894,22 @@ export async function mockApi(page: import("@playwright/test").Page) { team: { role: "lead", team_id: "t1" }, }; if (!sessions.includes(lead)) sessions.unshift(lead); - for (const [actor, status, item] of [ - ["swe-worker", "in_progress", "#1 in progress"], - ["design-worker", "idle", "idle"], - ["test-worker", "blocked", "#4 blocked"], + lead.team = { + role: "lead", + team_id: "t1", + chat_enabled: !!msg.enable_chat, + chat_unread: msg.enable_chat ? 1 : 0, + }; + for (const [actor, persona, status, item] of [ + ["nia", "swe-worker", "in_progress", "#1 in progress"], + ["webb", "design-worker", "idle", "idle"], + ["checks", "test-worker", "blocked", "#4 blocked"], ] as const) { sessions.push({ session_id: `sess-${actor}`, title: actor, workspace: "/Users/test/OpenWorker/launch-note", - agent: actor, + agent: persona, model: "m", mode: "interactive", updated_at: new Date().toISOString(), @@ -908,7 +925,7 @@ export async function mockApi(page: import("@playwright/test").Page) { }); } send("assistant_message", { - text: "Team created — swe-worker, design-worker and test-worker are standing by. Assigning items now.", + text: "Team created — nia, webb and checks are standing by. Assigning items now.", }); } else { send("assistant_message", { text: "Understood — tell me how to change the roster." }); @@ -1019,6 +1036,34 @@ export async function mockApi(page: import("@playwright/test").Page) { return json(item); } if (/\/v1\/sessions\/[^/]+\/board$/.test(p)) return json(boardPayload()); + // # team chat (OPE-99): one group, message log, user posts append. + if (/\/v1\/teams\/[^/]+\/chat$/.test(p)) { + if (m === "POST") { + const b = req.postDataJSON() || {}; + chatMessages.push({ + seq: chatMessages.length + 1, + ts: new Date().toISOString(), + author: "user", + author_role: "user", + text: String(b.text || ""), + mentions: ["nia", "webb", "checks", "lead"].filter((h) => + String(b.text || "").includes(`@${h}`), + ), + }); + return json(chatMessages[chatMessages.length - 1]); + } + return json({ + enabled: true, + team_id: "t1", + members: [ + { name: "nia", persona: "swe-worker", role: "worker" }, + { name: "webb", persona: "design-worker", role: "worker" }, + { name: "checks", persona: "test-worker", role: "worker" }, + { name: "lead", persona: "swe-lead", role: "lead" }, + ], + messages: chatMessages, + }); + } if (p.endsWith("/v1/teams/journal")) { return json({ cases: boardItems.length diff --git a/surfaces/gui/e2e/team.spec.ts b/surfaces/gui/e2e/team.spec.ts index 656be826..f46cf156 100644 --- a/surfaces/gui/e2e/team.spec.ts +++ b/surfaces/gui/e2e/team.spec.ts @@ -41,18 +41,60 @@ test("declining the split returns feedback to the lead", async ({ page }) => { await expect(page.getByText(/reworking the split/)).toBeVisible(); }); -test("the staffing gate shows the roster and the grant sentence", async ({ page }) => { +test("the staffing gate shows named workers, the chat toggle, and the grant sentence", async ({ + page, +}) => { await proposeTeam(page); const card = page.getByTestId("teamreq-card"); await expect(card).toContainText("Proposed team — 3 workers"); + // callnames lead the rows; persona + reason follow + await expect(card).toContainText("nia"); await expect(card).toContainText("swe-worker"); await expect(card).toContainText("implementation"); - await expect(card).toContainText("test-worker"); + await expect(card).toContainText("checks"); + // the chat checkbox defaults OFF — the user's call, not the lead's + await expect(card.getByTestId("teamreq-chat-toggle")).not.toBeChecked(); await expect(card).toContainText( "Approving grants the lead create, assign & steer — this team only, revocable.", ); }); +test("enabling chat at the gate adds the # team chat row; posting works with mentions", async ({ + page, +}) => { + await proposeTeam(page); + await page.getByTestId("teamreq-chat-toggle").check(); + await page.getByTestId("teamreq-approve").click(); + await expect(page.getByText(/Team created/)).toBeVisible(); + + await page.getByTestId("team-toggle-sess-lead").click(); + const chatRow = page.getByTestId("team-chat-row-sess-lead"); + await expect(chatRow).toBeVisible(); + await expect(chatRow).toContainText("1"); // unread badge + + await chatRow.click(); + const view = page.getByTestId("teamchat-view"); + await expect(view).toBeVisible(); + await expect(view).toContainText("assets bucket is public"); + await expect(view.locator(".chat-mention").first()).toHaveText("@nia"); + + await page.getByTestId("chat-input").fill("ship it current-month only @lead"); + await page.getByTestId("chat-send").click(); + await expect(view).toContainText("ship it current-month only"); + + await page.keyboard.press("Escape"); + await expect(page.getByTestId("teamchat-view")).toHaveCount(0); +}); + +test("with chat declined at the gate, no chat row renders", async ({ page }) => { + await proposeTeam(page); + await page.getByTestId("teamreq-approve").click(); + await expect(page.getByText(/Team created/)).toBeVisible(); + await page.getByTestId("team-toggle-sess-lead").click(); + await expect(page.getByTestId("team-children-sess-lead")).toBeVisible(); + await expect(page.getByTestId("team-chat-row-sess-lead")).toHaveCount(0); +}); + test("declining the roster returns the turn to the lead", async ({ page }) => { await proposeTeam(page); await page.getByRole("button", { name: "Not now" }).click(); @@ -76,9 +118,9 @@ test("approval creates the team; workers nest under the lead's expandable entry" await page.getByTestId("team-toggle-sess-lead").click(); const children = page.getByTestId("team-children-sess-lead"); await expect(children).toBeVisible(); - await expect(children).toContainText("swe-worker · #1 in progress"); - await expect(children).toContainText("design-worker · idle"); - await expect(children).toContainText("test-worker · #4 blocked"); + await expect(children).toContainText("nia · #1 in progress"); + await expect(children).toContainText("webb · idle"); + await expect(children).toContainText("checks · #4 blocked"); // Collapse hides them again — the team is one entry, not a panel. await page.getByTestId("team-toggle-sess-lead").click(); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 4a9f636b..b7793f3d 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -78,6 +78,7 @@ import { PlanCard } from "./components/PlanCard"; import { BoardOverlay } from "./components/BoardPanel"; import { TeamRequestCard } from "./components/TeamRequestCard"; import { WorkItemsCard } from "./components/WorkItemsCard"; +import { TeamChatView } from "./components/TeamChatView"; import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt"; const newId = () => @@ -276,6 +277,8 @@ export function App() { // Agent teams (OPE-96): board for the current session's workspace space. const [board, setBoard] = useState(null); const [boardOpen, setBoardOpen] = useState(false); + // # team chat overlay — opened from the team entry's chat row. + const [chatTeam, setChatTeam] = useState(null); const [railHidden, setRailHidden] = useState(false); // Left-nav collapse (⌘B): when collapsed the sidebar leaves the grid so content reclaims the // width; hovering the left edge peeks it back as a floating overlay. Persisted per-device. @@ -1043,10 +1046,10 @@ export function App() { sessionRef.current?.respondPlan(approved, mode, feedback); if (approved && mode) setMode(mode); // the server flips the live engine to this mode }; - const respondTeam = (approved: boolean, feedback?: string) => { + const respondTeam = (approved: boolean, feedback?: string, enableChat?: boolean) => { setItems((p) => resolveLastTeam(p, approved ? "approved" : "rejected")); dropSessionInbox("plan"); // the gate parks as a plan-kind Inbox item - sessionRef.current?.respondTeam(approved, feedback); + sessionRef.current?.respondTeam(approved, feedback, enableChat); }; const respondItemsReq = (approved: boolean, feedback?: string) => { setItems((p) => resolveLastItemsReq(p, approved ? "approved" : "rejected")); @@ -1599,6 +1602,7 @@ export function App() { sessions={sessions} projects={projects} activeSession={sessionId} + onOpenTeamChat={(teamId) => setChatTeam(teamId)} onSwitchAgent={switchAgent} onNewSession={startNewSession} onSelectSession={selectSession} @@ -2006,6 +2010,7 @@ export function App() { {boardOpen && board && board.space && ( setBoardOpen(false)} onTransition={moveBoardItem} /> )} + {chatTeam && setChatTeam(null)} />} )} diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index b428f65f..83baa7bb 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -253,6 +253,36 @@ export async function boardTransition( return res.json(); } +export interface ChatMessage { + seq: number; + ts: string; + author: string; + author_role: "user" | "lead" | "worker" | string; + text: string; + mentions: string[]; +} + +export interface TeamChat { + enabled: boolean; + team_id?: string; + members: { name: string; persona: string; role: string }[]; + messages: ChatMessage[]; +} + +export async function getTeamChat(teamId: string): Promise { + const res = await fetch(`${httpBase()}/v1/teams/${encodeURIComponent(teamId)}/chat`); + return res.json(); +} + +export async function postTeamChat(teamId: string, text: string): Promise { + const res = await fetch(`${httpBase()}/v1/teams/${encodeURIComponent(teamId)}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + }); + return res.json(); +} + export async function getJournalCases(): Promise { const res = await fetch(`${httpBase()}/v1/teams/journal`); return (await res.json()).cases ?? []; @@ -2189,11 +2219,12 @@ export class Session { }); } - respondTeam(approved: boolean, feedback?: string) { + respondTeam(approved: boolean, feedback?: string, enableChat?: boolean) { this.send({ type: "team_response", approved, ...(feedback ? { feedback } : {}), + ...(enableChat !== undefined ? { enable_chat: enableChat } : {}), }); } diff --git a/surfaces/gui/src/components/Sidebar.tsx b/surfaces/gui/src/components/Sidebar.tsx index 27fff0a0..3bcf2120 100644 --- a/surfaces/gui/src/components/Sidebar.tsx +++ b/surfaces/gui/src/components/Sidebar.tsx @@ -121,6 +121,8 @@ interface Props { onSwitchAgent: (agent: string) => void; onNewSession: (agent: string) => void; onSelectSession: (id: string, workspace: string, agent: string) => void; + // Agent teams: opens the team's # team chat view (the row under the expandable entry). + onOpenTeamChat?: (teamId: string) => void; onNewProject: (persona: string) => void; onRenameSession: (id: string, title: string) => void; onDeleteSession: (id: string) => void; @@ -728,6 +730,19 @@ export function Sidebar(props: Props) { ))} + {s.team?.chat_enabled && props.onOpenTeamChat && ( +
props.onOpenTeamChat?.(s.team?.team_id || "")} + > + # + team chat + {(s.team?.chat_unread || 0) > 0 && ( + {s.team?.chat_unread} + )} +
+ )} ); }; diff --git a/surfaces/gui/src/components/TeamChatView.tsx b/surfaces/gui/src/components/TeamChatView.tsx new file mode 100644 index 00000000..02bfc736 --- /dev/null +++ b/surfaces/gui/src/components/TeamChatView.tsx @@ -0,0 +1,129 @@ +// # team chat (agent teams, OPE-99): a minimal Slack-shaped exception channel over +// the session area — author-grouped messages, @mention highlighting, composer that +// posts as [User] (which wakes every member; agent posts wake mentions only). +// No derived board-event clusters (owner call, eighth pass): status lives on the +// board rail one click away — this surface is pure messages. +import { useEffect, useRef, useState } from "react"; +import { getTeamChat, postTeamChat, type TeamChat } from "../api"; +import { Icon } from "./Icon"; + +function mentionify(text: string, members: Set) { + // Split on @word tokens; wrap known handles in a highlight span. + const parts = text.split(/(@[\w.-]+)/g); + return parts.map((part, i) => + part.startsWith("@") && members.has(part.slice(1)) ? ( + + {part} + + ) : ( + {part} + ), + ); +} + +function clock(ts: string): string { + const d = new Date(ts); + return isNaN(d.getTime()) + ? "" + : d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +} + +export function TeamChatView({ teamId, onClose }: { teamId: string; onClose: () => void }) { + const [chat, setChat] = useState(null); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const bottom = useRef(null); + + const load = () => getTeamChat(teamId).then(setChat).catch(() => {}); + useEffect(() => { + load(); + const t = setInterval(load, 3000); + return () => clearInterval(t); + }, [teamId]); + useEffect(() => { + bottom.current?.scrollIntoView({ block: "end" }); + }, [chat?.messages.length]); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const send = async () => { + const text = draft.trim(); + if (!text || busy) return; + setBusy(true); + try { + await postTeamChat(teamId, text); + setDraft(""); + await load(); + } finally { + setBusy(false); + } + }; + + const handles = new Set((chat?.members || []).map((m) => m.name)); + const messages = chat?.messages || []; + + return ( +
+
e.stopPropagation()}> +
+
+ # + team chat + questions & consensus — status lives on the board +
+ +
+
+ {messages.length === 0 && ( +
+ No messages yet. Agents post here only when something needs a reply — + @mention a coworker to reach it. +
+ )} + {messages.map((m, i) => { + const grouped = i > 0 && messages[i - 1].author === m.author; + const label = m.author_role === "user" ? "You" : m.author; + return ( +
+ {!grouped && ( +
+ + {label.slice(0, 1).toUpperCase()} + + {label} + {m.author_role === "user" ? "" : m.author_role} + {clock(m.ts)} +
+ )} +
{mentionify(m.text, handles)}
+
+ ); + })} +
+
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") send(); + }} + /> + +
+
+
+ ); +} diff --git a/surfaces/gui/src/components/TeamRequestCard.tsx b/surfaces/gui/src/components/TeamRequestCard.tsx index b5aeb157..ec3d50bf 100644 --- a/surfaces/gui/src/components/TeamRequestCard.tsx +++ b/surfaces/gui/src/components/TeamRequestCard.tsx @@ -1,7 +1,9 @@ // The staffing gate (agent teams, UX-030): a lead proposes its worker roster. -// Visible layer = the decisions (who, on what model, why); approving grants the lead -// create/assign/steer for this board — standing, revocable — and PRE-SPAWNS the -// worker sessions. No in-card reply surface: editing happens by replying. +// Visible layer = the decisions (who — by callname — on what model, why); approving +// grants the lead create/assign/steer for this board — standing, revocable — and +// PRE-SPAWNS the worker sessions. The chat checkbox is the USER's call (default +// OFF, ⓘ per mock); no in-card reply surface: editing happens by replying. +import { useState } from "react"; import type { Item } from "../types"; import { Icon } from "./Icon"; @@ -10,8 +12,9 @@ export function TeamRequestCard({ onRespond, }: { item: Extract; - onRespond: (approved: boolean, feedback?: string) => void; + onRespond: (approved: boolean, feedback?: string, enableChat?: boolean) => void; }) { + const [chat, setChat] = useState(!!item.enable_chat); return (
@@ -25,12 +28,31 @@ export function TeamRequestCard({
+ {m.name && {m.name}} + {m.name ? " — " : ""} {m.persona} {m.model && · {m.model}} {m.reason && — {m.reason}}
))} +
Approving grants the lead create, assign & steer — this team only, revocable. @@ -42,7 +64,7 @@ export function TeamRequestCard({ diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index 4f890678..befc9c22 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -1748,3 +1748,31 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color: .itemsreq-ac b { font-weight: 600; } .itemsreq-more { display: flex; align-items: center; gap: 5px; border: 0; background: transparent; color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0; } .itemsreq-grant { font-size: 11.5px; color: var(--faint); } + +/* # team chat */ +.teamreq-name { color: var(--ink); font-weight: 600; } +.teamreq-chat { display: flex; align-items: center; gap: 8px; padding: 9px 0 2px; border-top: 1px solid var(--line); font-size: 12.5px; color: var(--ink); cursor: pointer; } +.teamreq-info { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; border: 1px solid var(--line-strong); border-radius: 50%; color: var(--faint); font-size: 9px; cursor: help; } +.chat-panel { max-width: 860px; } +.chat-hash { color: var(--faint); font-weight: 700; } +.chat-scroll { flex: 1; overflow: auto; padding: 14px 18px; display: flex; flex-direction: column; } +.chat-empty { color: var(--faint); font-size: 12.5px; margin: auto; max-width: 420px; text-align: center; } +.chat-msg { margin-top: 12px; } +.chat-msg.grouped { margin-top: 2px; padding-left: 34px; } +.chat-who { display: flex; align-items: baseline; gap: 8px; } +.chat-avatar { width: 26px; height: 26px; border-radius: 7px; background: var(--paper); border: 1px solid var(--line-strong); display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; align-self: center; } +.chat-avatar.lead { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); } +.chat-avatar.user { background: var(--ok-soft); border-color: var(--ok-line); color: var(--ok); } +.chat-name { font-size: 12.5px; font-weight: 700; color: var(--ink); } +.chat-role { font-size: 11px; color: var(--faint); } +.chat-ts { font-size: 10.5px; color: var(--faint); } +.chat-text { font-size: 13px; color: var(--ink); margin-top: 1px; padding-left: 34px; } +.chat-msg.grouped .chat-text { padding-left: 0; } +.chat-mention { color: var(--accent); background: var(--accent-soft); border-radius: 4px; padding: 0 3px; } +.chat-composer { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--line); } +.chat-input { flex: 1; border: 1px solid var(--line); border-radius: 10px; padding: 9px 12px; font-size: 13px; background: var(--paper); color: var(--ink); outline: none; } +.chat-input:focus { border-color: var(--accent); } + +/* Sidebar chat row */ +.team-hash { color: var(--faint); font-weight: 700; font-size: 12px; width: 10px; text-align: center; } +.team-chat-badge { margin-left: auto; background: var(--accent); color: #fff; font-size: 10px; font-weight: 700; border-radius: 8px; padding: 0 6px; } diff --git a/surfaces/gui/src/types.ts b/surfaces/gui/src/types.ts index 7693e4c3..020f4e7e 100644 --- a/surfaces/gui/src/types.ts +++ b/surfaces/gui/src/types.ts @@ -95,6 +95,8 @@ export interface SessionInfo { actor?: string; current_item?: string; status?: string; + chat_enabled?: boolean; + chat_unread?: number; }; } @@ -160,7 +162,7 @@ export type Item = | { // The staffing gate (agent teams): a lead proposes its worker roster. kind: "teamreq"; - members: { persona: string; model?: string; reason?: string }[]; + members: { persona: string; name?: string; model?: string; reason?: string }[]; enable_chat?: boolean; note?: string; resolved?: "approved" | "rejected"; diff --git a/tests/test_team_wake.py b/tests/test_team_wake.py index 70ee36e1..02f41bf2 100644 --- a/tests/test_team_wake.py +++ b/tests/test_team_wake.py @@ -266,3 +266,83 @@ def test_turn_saves_never_detach_a_worker_from_its_team(manager, monkeypatch): ) 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 = 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_cancel_notice_is_addressed_to_the_assignee(store): + item_id = assigned(store) + store.consume("swe-worker", store.pending_for("swe-worker")[-1]["seq"]) + store.transition(SPACE, LEAD, item_id, "canceled", comment="scope cut") + pending = store.pending_for("swe-worker") + assert len(pending) == 1 + assert pending[0]["kind"] == "item_transitioned" + assert pending[0]["payload"]["to"] == "canceled"