# team chat: own chat store, named workers, mention wakes, cancel interrupt (OPE-99)

ChatStore = groups + append-only messages + per-member cursors; agent posts wake mentions only, user posts wake everyone; post_chat(record_on_item) also lands the answer as an item comment.
Leads name workers (the callname is the handle everywhere); worker digests auto-carry the roster; gate checkbox is the user's call; canceling an assigned item now interrupts an in-flight worker.
This commit is contained in:
Rohit C Prasad
2026-08-16 16:22:23 -07:00
committed by Rohit P
parent 844a6510a2
commit cf436d1069
20 changed files with 899 additions and 44 deletions
@@ -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 =
@@ -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.
@@ -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):
@@ -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.
+21 -1
View File
@@ -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 {}
),
}
)
)
+207 -8
View File
@@ -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(
+215
View File
@@ -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
+5 -1
View File
@@ -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
+11
View File
@@ -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,
+7 -4
View File
@@ -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"},
+55 -10
View File
@@ -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
+47 -5
View File
@@ -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();
+7 -2
View File
@@ -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<Board | null>(null);
const [boardOpen, setBoardOpen] = useState(false);
// # team chat overlay — opened from the team entry's chat row.
const [chatTeam, setChatTeam] = useState<string | null>(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 && (
<BoardOverlay board={board} onClose={() => setBoardOpen(false)} onTransition={moveBoardItem} />
)}
{chatTeam && <TeamChatView teamId={chatTeam} onClose={() => setChatTeam(null)} />}
</div>
</div>
)}
+32 -1
View File
@@ -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<TeamChat> {
const res = await fetch(`${httpBase()}/v1/teams/${encodeURIComponent(teamId)}/chat`);
return res.json();
}
export async function postTeamChat(teamId: string, text: string): Promise<ChatMessage | { error: string }> {
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<JournalCase[]> {
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 } : {}),
});
}
+15
View File
@@ -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) {
<LiveDot state={w.liveness} />
</div>
))}
{s.team?.chat_enabled && props.onOpenTeamChat && (
<div
className="group flex items-center gap-2 px-2 py-1 rounded-lg cursor-pointer text-[12px] hover:bg-paper"
data-testid={`team-chat-row-${s.session_id}`}
onClick={() => props.onOpenTeamChat?.(s.team?.team_id || "")}
>
<span className="team-hash">#</span>
<span className="min-w-0 flex-1 truncate text-ink">team chat</span>
{(s.team?.chat_unread || 0) > 0 && (
<span className="team-chat-badge">{s.team?.chat_unread}</span>
)}
</div>
)}
</div>
);
};
@@ -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<string>) {
// 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)) ? (
<span className="chat-mention" key={i}>
{part}
</span>
) : (
<span key={i}>{part}</span>
),
);
}
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<TeamChat | null>(null);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const bottom = useRef<HTMLDivElement | null>(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 (
<div className="board-overlay" data-testid="teamchat-view" onClick={onClose}>
<div className="board-overlay-panel chat-panel" onClick={(e) => e.stopPropagation()}>
<div className="board-overlay-head">
<div className="board-overlay-title">
<span className="chat-hash">#</span>
<span>team chat</span>
<span className="board-overlay-space">questions &amp; consensus status lives on the board</span>
</div>
<button className="artifact-icon-btn" onClick={onClose} aria-label="Close chat" title="Close">
<Icon name="x" size={16} />
</button>
</div>
<div className="chat-scroll">
{messages.length === 0 && (
<div className="chat-empty">
No messages yet. Agents post here only when something needs a reply
@mention a coworker to reach it.
</div>
)}
{messages.map((m, i) => {
const grouped = i > 0 && messages[i - 1].author === m.author;
const label = m.author_role === "user" ? "You" : m.author;
return (
<div className={"chat-msg" + (grouped ? " grouped" : "")} key={m.seq}>
{!grouped && (
<div className="chat-who">
<span className={"chat-avatar " + m.author_role}>
{label.slice(0, 1).toUpperCase()}
</span>
<span className="chat-name">{label}</span>
<span className="chat-role">{m.author_role === "user" ? "" : m.author_role}</span>
<span className="chat-ts">{clock(m.ts)}</span>
</div>
)}
<div className="chat-text">{mentionify(m.text, handles)}</div>
</div>
);
})}
<div ref={bottom} />
</div>
<div className="chat-composer">
<input
className="chat-input"
data-testid="chat-input"
placeholder="Message # team chat… (posts as you — every member sees it)"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") send();
}}
/>
<button className="btn primary" data-testid="chat-send" disabled={busy || !draft.trim()} onClick={send}>
Send
</button>
</div>
</div>
</div>
);
}
@@ -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<Item, { kind: "teamreq" }>;
onRespond: (approved: boolean, feedback?: string) => void;
onRespond: (approved: boolean, feedback?: string, enableChat?: boolean) => void;
}) {
const [chat, setChat] = useState(!!item.enable_chat);
return (
<div className="dirreq-card teamreq-card" data-testid="teamreq-card">
<div className="teamreq-head">
@@ -25,12 +28,31 @@ export function TeamRequestCard({
<div className="teamreq-row" key={i}>
<span className="teamreq-diamond"></span>
<span className="teamreq-body">
{m.name && <b className="teamreq-name">{m.name}</b>}
{m.name ? " — " : ""}
<code>{m.persona}</code>
{m.model && <span className="teamreq-model"> · {m.model}</span>}
{m.reason && <span className="teamreq-reason"> {m.reason}</span>}
</span>
</div>
))}
<label className="teamreq-chat">
<input
type="checkbox"
data-testid="teamreq-chat-toggle"
checked={chat}
onChange={(e) => setChat(e.target.checked)}
/>
<span>
Enable <b># team chat</b>
</span>
<span
className="teamreq-info"
title="A group channel for questions and consensus — @mentions wake the mentioned coworker. Status stays on the board either way."
>
i
</span>
</label>
<div className="dirreq-actions">
<span className="teamreq-grant">
Approving grants the lead create, assign &amp; steer this team only, revocable.
@@ -42,7 +64,7 @@ export function TeamRequestCard({
<button
className="btn primary"
data-testid="teamreq-approve"
onClick={() => onRespond(true)}
onClick={() => onRespond(true, undefined, chat)}
>
Create team &amp; start
</button>
+28
View File
@@ -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; }
+3 -1
View File
@@ -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";
+80
View File
@@ -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"