mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 13:00:37 +00:00
# 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:
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & 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 & 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 & start
|
||||
</button>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user