Skip to content

Commit c305477

Browse files
committed
im: prefer actor titles over ids in IM display
1 parent 44d5d66 commit c305477

File tree

4 files changed

+130
-7
lines changed

4 files changed

+130
-7
lines changed

src/cccc/ports/im/bridge.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ def _is_env_var_name(value: str) -> bool:
5252
return bool(re.fullmatch(r"[A-Z_][A-Z0-9_]*", (value or "").strip()))
5353

5454

55+
_PRESERVED_RECIPIENT_TOKENS = frozenset({"user", "@user", "@all", "@peers", "@foreman"})
56+
57+
5558
def _acquire_singleton_lock(lock_path: Path) -> Optional[Any]:
5659
"""
5760
Acquire singleton lock to prevent multiple bridge instances.
@@ -466,11 +469,41 @@ def _process_outbound(self) -> None:
466469
self.key_manager._load()
467470

468471
events = self.watcher.poll()
472+
actor_labels = self._actor_display_map()
469473

470474
for event in events:
471-
self._forward_event(event)
472-
473-
def _forward_event(self, event: Dict[str, Any]) -> None:
475+
self._forward_event(event, actor_labels=actor_labels)
476+
477+
def _actor_display_map(self) -> Dict[str, str]:
478+
"""Build actor_id -> display label map (title first, id fallback)."""
479+
group = load_group(self.group.group_id) or self.group
480+
labels: Dict[str, str] = {}
481+
for actor in list_actors(group):
482+
if not isinstance(actor, dict):
483+
continue
484+
actor_id = str(actor.get("id") or "").strip()
485+
if not actor_id:
486+
continue
487+
title = str(actor.get("title") or "").strip()
488+
labels[actor_id] = title if title else actor_id
489+
return labels
490+
491+
def _display_actor_token(self, token: str, actor_labels: Dict[str, str]) -> str:
492+
"""Render actor/selector token for outbound IM display."""
493+
raw = str(token or "").strip()
494+
if not raw:
495+
return raw
496+
if raw in _PRESERVED_RECIPIENT_TOKENS:
497+
return raw
498+
if raw in actor_labels:
499+
return actor_labels[raw]
500+
if raw.startswith("@"):
501+
stripped = raw[1:].strip()
502+
if stripped in actor_labels:
503+
return actor_labels[stripped]
504+
return raw
505+
506+
def _forward_event(self, event: Dict[str, Any], *, actor_labels: Optional[Dict[str, str]] = None) -> None:
474507
"""Forward a ledger event to subscribed chats."""
475508
kind = event.get("kind", "")
476509
by = event.get("by", "")
@@ -503,6 +536,7 @@ def _forward_event(self, event: Dict[str, Any]) -> None:
503536
# Determine if this event is user-facing (to:user or broadcast).
504537
# Agent-to-agent messages should NOT cancel typing indicators.
505538
is_user_facing = not to or "user" in to
539+
display_labels = actor_labels or self._actor_display_map()
506540

507541
for sub in subscribed:
508542
# Safety filter: only authorized chats are allowed to receive bridge
@@ -517,7 +551,13 @@ def _forward_event(self, event: Dict[str, Any]) -> None:
517551
continue
518552

519553
# Format message (may be empty for file-only events)
520-
formatted = self.adapter.format_outbound(by, to, text, is_system) if text else ""
554+
display_by = self._display_actor_token(by, display_labels)
555+
display_to = [
556+
self._display_actor_token(str(t), display_labels)
557+
for t in (to if isinstance(to, list) else [])
558+
if str(t or "").strip()
559+
]
560+
formatted = self.adapter.format_outbound(display_by, display_to, text, is_system) if text else ""
521561

522562
# Try file delivery first (if any attachments)
523563
sent_any_file = False

src/cccc/ports/im/commands.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,13 @@ def format_status(
217217
actors: List[dict],
218218
) -> str:
219219
"""Format status response."""
220+
def _actor_label(actor_doc: dict) -> str:
221+
actor_id = str(actor_doc.get("id") or "").strip() or "?"
222+
title = str(actor_doc.get("title") or "").strip()
223+
if title:
224+
return title
225+
return actor_id
226+
220227
lines = [f"📊 {group_title}"]
221228
lines.append(f"State: {group_state} | Running: {'✓' if running else '✗'}")
222229
lines.append("")
@@ -226,13 +233,13 @@ def format_status(
226233
else:
227234
lines.append("Actors:")
228235
for actor in actors:
229-
actor_id = actor.get("id", "?")
236+
actor_label = _actor_label(actor)
230237
role = actor.get("role", "peer")
231238
is_running = actor.get("running", False)
232239
runtime = actor.get("runtime", "codex")
233240
status_icon = "🟢" if is_running else "⚪"
234241
role_icon = "👑" if role == "foreman" else "👤"
235-
lines.append(f" {status_icon} {role_icon} {actor_id} ({runtime})")
242+
lines.append(f" {status_icon} {role_icon} {actor_label} ({runtime})")
236243

237244
return "\n".join(lines)
238245

tests/test_im_auth.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,11 @@ class _FakeAdapter:
263263

264264
def __init__(self) -> None:
265265
self.sent_messages: list[tuple[str, str, int]] = []
266+
self.formatted_calls: list[tuple[str, list[str], str, bool]] = []
266267

267268
def format_outbound(self, by: str, to: object, text: str, is_system: bool) -> str:
268-
_ = (by, to, is_system)
269+
to_list = [str(item) for item in to] if isinstance(to, list) else []
270+
self.formatted_calls.append((str(by), to_list, str(text or ""), bool(is_system)))
269271
return str(text or "")
270272

271273
def send_file(self, chat_id: str, file_path: Path, filename: str, caption: str = "", thread_id: int = 0) -> bool:
@@ -320,6 +322,46 @@ def test_process_outbound_reloads_auth_and_blocks_revoked_chat(self) -> None:
320322

321323
self.assertEqual(adapter.sent_messages, [])
322324

325+
def test_outbound_header_uses_actor_title_first(self) -> None:
326+
from cccc.ports.im.bridge import IMBridge
327+
from cccc.ports.im.subscribers import SubscriberManager
328+
329+
km = KeyManager(self.state_dir)
330+
sm = SubscriberManager(self.state_dir)
331+
key = km.generate_key("chat_auth", 0, "telegram")
332+
km.authorize("chat_auth", 0, "telegram", key)
333+
sm.subscribe("chat_auth", chat_title="auth", thread_id=0, platform="telegram")
334+
335+
fake_group = SimpleNamespace(
336+
group_id="g_demo",
337+
path=self.group_path,
338+
ledger_path=self.group_path / "ledger.jsonl",
339+
doc={
340+
"title": "demo",
341+
"im": {},
342+
"actors": [
343+
{"id": "foreman", "title": "Captain"},
344+
{"id": "peer_a", "title": "Reviewer"},
345+
],
346+
},
347+
)
348+
adapter = self._FakeAdapter()
349+
bridge = IMBridge(group=fake_group, adapter=adapter)
350+
351+
bridge.watcher.poll = lambda: [ # type: ignore[method-assign]
352+
{
353+
"kind": "chat.message",
354+
"by": "foreman",
355+
"data": {"text": "review this", "to": ["@all", "peer_a"], "attachments": []},
356+
}
357+
]
358+
bridge._process_outbound()
359+
360+
self.assertEqual(len(adapter.formatted_calls), 1)
361+
by, to, _text, _is_system = adapter.formatted_calls[0]
362+
self.assertEqual(by, "Captain")
363+
self.assertEqual(to, ["@all", "Reviewer"])
364+
323365

324366
try:
325367
from cccc.daemon.ops.im_ops import _load_km

tests/test_im_commands.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import unittest
2+
3+
from cccc.ports.im.commands import format_status
4+
5+
6+
class TestImFormatStatus(unittest.TestCase):
7+
def test_format_status_uses_title_then_id(self) -> None:
8+
text = format_status(
9+
group_title="demo",
10+
group_state="active",
11+
running=True,
12+
actors=[
13+
{"id": "foreman", "title": "Planner", "role": "foreman", "running": True, "runtime": "claude"},
14+
{"id": "peer_a", "title": "", "role": "peer", "running": False, "runtime": "codex"},
15+
],
16+
)
17+
self.assertIn("Planner (claude)", text)
18+
self.assertIn("peer_a (codex)", text)
19+
20+
def test_format_status_avoids_duplicate_when_title_equals_id(self) -> None:
21+
text = format_status(
22+
group_title="demo",
23+
group_state="active",
24+
running=True,
25+
actors=[
26+
{"id": "peer_a", "title": "peer_a", "role": "peer", "running": True, "runtime": "codex"},
27+
],
28+
)
29+
self.assertIn("peer_a (codex)", text)
30+
self.assertNotIn("(@peer_a)", text)
31+
32+
33+
if __name__ == "__main__":
34+
unittest.main()

0 commit comments

Comments
 (0)