Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions codec_agent_messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,22 @@ def post_message(agent_id: str, type: str, title: str, body: str,

_atomic_write_json(_NOTIFICATIONS_PATH, notifs)

# Phase 3.5 — multi-channel notification dispatch.
# Reads agent's notification_channels from manifest. Each non-`pwa`
# channel gets its own dispatch (best-effort; failures don't block).
if not is_silenced(agent_id):
try:
channels = _agent_notification_channels(agent_id)
for ch in channels:
if ch == "pwa":
continue # already covered by notifications.json above
try:
_dispatch_to_channel(ch, agent_id, title, body, type)
except Exception as e:
log.debug("[%s] channel %s dispatch failed: %s", agent_id, ch, e)
except Exception as e:
log.debug("[%s] channel dispatch wrapper failed: %s", agent_id, e)

# Audit emit
_audit(AGENT_MESSAGE_SENT, message=f"{type} for {agent_id}",
correlation_id=correlation_id,
Expand All @@ -222,6 +238,106 @@ def post_message(agent_id: str, type: str, title: str, body: str,
return record


def _agent_notification_channels(agent_id: str) -> List[str]:
"""Read manifest.notification_channels. Defaults to ['pwa']."""
manifest_path = _AGENTS_DIR / agent_id / "manifest.json"
if not manifest_path.exists():
return ["pwa"]
try:
data = json.loads(manifest_path.read_text())
chs = data.get("notification_channels") or ["pwa"]
return [c for c in chs if isinstance(c, str)] or ["pwa"]
except Exception:
return ["pwa"]


def _dispatch_to_channel(channel: str, agent_id: str,
title: str, body: str, msg_type: str) -> None:
"""Best-effort dispatch to a single channel. Raises on hard failures.

Supported channels:
- "macos": macOS notification banner via osascript display notification
- "imessage": send via codec_imessage.send_message helper if available
- "telegram": send via codec_telegram.send_message helper if available

Phase 3.5 multi-channel notifications. Each channel is OPTIONAL —
if the underlying tooling isn't configured, dispatch is a no-op.
"""
short_body = (body or "")[:200]
short_title = (title or f"CODEC agent {agent_id}")[:80]

if channel == "macos":
# macOS notification banner via osascript. No external dependencies.
import subprocess
# Sanitize for AppleScript single-quoting
def _esc(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"')
script = (
f'display notification "{_esc(short_body)}" '
f'with title "{_esc(short_title)}" '
f'subtitle "agent: {_esc(agent_id)}"'
)
subprocess.run(["osascript", "-e", script], timeout=5,
capture_output=True, check=False)
return

if channel == "imessage":
# Reuse the imessage_send skill's _send helper. Recipient is read
# from ~/.codec/config.json:notifications.imessage_recipient
# (phone number or Apple ID). If unset, skip silently.
recipient = _channel_config("imessage_recipient")
if not recipient:
log.debug("notifications.imessage_recipient not configured; skipping imessage")
return
try:
import sys as _sys
from pathlib import Path as _Path
skills_dir = str(_Path(__file__).resolve().parent / "skills")
if skills_dir not in _sys.path:
_sys.path.insert(0, skills_dir)
import imessage_send as _ims
_ims._send(recipient, f"[{short_title}]\n{short_body}")
except Exception as e:
log.debug("imessage send failed: %s", e)
return

if channel == "telegram":
# Send via Telegram Bot API directly (avoids tight coupling to
# codec_telegram.py's daemon internals). Reads token + chat_id
# from ~/.codec/config.json:notifications.{telegram_token,telegram_chat_id}.
token = _channel_config("telegram_token")
chat_id = _channel_config("telegram_chat_id")
if not token or not chat_id:
log.debug("notifications.telegram_{token,chat_id} not configured; skipping telegram")
return
try:
import requests
requests.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json={"chat_id": chat_id,
"text": f"*{short_title}*\n{short_body}",
"parse_mode": "Markdown"},
timeout=5,
)
except Exception as e:
log.debug("telegram send failed: %s", e)
return

log.debug("unknown notification channel: %s", channel)


def _channel_config(key: str) -> str:
"""Read ~/.codec/config.json:notifications.<key>. Empty string if unset."""
cfg_path = _CODEC_DIR / "config.json"
if not cfg_path.exists():
return ""
try:
data = json.loads(cfg_path.read_text())
return str((data.get("notifications") or {}).get(key) or "")
except Exception:
return ""


def post_user_reply(agent_id: str, body: str) -> Dict[str, Any]:
"""User → agent reply. Written to messages.jsonl with type=user_reply.
Daemon picks up next tick, feeds to next _qwen_next_action call.
Expand Down
68 changes: 68 additions & 0 deletions tests/test_agent_messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,3 +377,71 @@ def test_post_api_agents_silence_toggles_state(temp_codec_dir):
r2 = client.post("/api/agents/a1/silence", json={"silenced": False})
assert r2.status_code == 200
assert cam.is_silenced("a1") is False


# ─────────────────────────────────────────────────────────────────────────────
# Phase 3.5 — Multi-channel notification dispatch (5 tests)
# ─────────────────────────────────────────────────────────────────────────────

def test_pwa_only_default_no_extra_dispatch(monkeypatch, temp_codec_dir):
"""Default channels=['pwa']: no macOS/iMessage/Telegram dispatch."""
import codec_agent_messaging as cam
import codec_agent_plan as cap
cap.save_manifest("a1", {"agent_id": "a1", "title": "x",
"notification_channels": ["pwa"]})
dispatched = []
monkeypatch.setattr(cam, "_dispatch_to_channel",
lambda ch, *a, **k: dispatched.append(ch))
cam.post_message(agent_id="a1", type="agent_update", title="t",
body="b", actions=[], correlation_id="cid")
assert dispatched == [] # pwa is skipped (handled inline)


def test_macos_channel_dispatched_when_configured(monkeypatch, temp_codec_dir):
"""When manifest includes 'macos', _dispatch_to_channel called for it."""
import codec_agent_messaging as cam
import codec_agent_plan as cap
cap.save_manifest("a1", {"agent_id": "a1", "title": "x",
"notification_channels": ["pwa", "macos"]})
dispatched = []
monkeypatch.setattr(cam, "_dispatch_to_channel",
lambda ch, *a, **k: dispatched.append(ch))
cam.post_message(agent_id="a1", type="agent_update", title="t",
body="b", actions=[], correlation_id="cid")
assert dispatched == ["macos"]


def test_dispatch_to_channel_macos_invokes_osascript(monkeypatch, temp_codec_dir):
"""_dispatch_to_channel('macos', ...) builds an osascript command and runs it."""
import codec_agent_messaging as cam
captured = {"args": None}
class FakeProc:
returncode = 0
def fake_run(args, **kw):
captured["args"] = list(args)
return FakeProc()
import subprocess
monkeypatch.setattr(subprocess, "run", fake_run)
cam._dispatch_to_channel("macos", "agent_test", "Test title", "Test body", "agent_update")
assert captured["args"] is not None
assert captured["args"][0] == "osascript"
assert "-e" in captured["args"]
# AppleScript text contains the title + body somewhere
full = " ".join(captured["args"])
assert "Test title" in full
assert "Test body" in full


def test_imessage_channel_skipped_when_recipient_unset(monkeypatch, temp_codec_dir):
"""Without config notifications.imessage_recipient, channel is no-op (no exception)."""
import codec_agent_messaging as cam
# No config.json → _channel_config returns ""
cam._dispatch_to_channel("imessage", "agent_test", "T", "B", "agent_update")
# Should not raise; should not call any send


def test_telegram_channel_skipped_when_unconfigured(monkeypatch, temp_codec_dir):
"""Without telegram token/chat_id, channel is no-op."""
import codec_agent_messaging as cam
cam._dispatch_to_channel("telegram", "agent_test", "T", "B", "agent_update")
# Should not raise
Loading