Voice utterances and text messages on the same Discord channel now share one Claude session, and Echo's voice replies are mirrored back into the text channel. Replaces the old voice:<id> session-key split. Changes: - src/adapters/_text_chunks.py: new leaf module for split_message (used by both discord_bot and voice pipeline) - src/router.py: drop voice: prefix from session_key; add [voice] marker; strip leading [speaker:/[voice] tokens from user input (anti-jailbreak); remove dead double-clear of voice: key - src/claude_session.py: include personality/VOICE_MODE.md unconditionally (rules become per-turn-aware via [speaker:] prefix instead of session flag) - src/voice/pipeline.py: VoiceSession splits text_channel_id + voice_channel_id; resolve text channel per-send (no stale refs); mirror Echo's reply text into the text channel after route_message returns - src/adapters/discord_voice.py: /voice join passes both channel ids - src/adapters/discord_bot.py: import split_message from leaf module - personality/VOICE_MODE.md: rewrite as per-turn dynamic rules; add synthesis instructions for text turns after voice turns Tests: - tests/test_router.py: 4 new cases (plain channel_id, anti-jailbreak, text-adapter regression, no-double-clear) - tests/test_pipeline_mirror.py: new — Echo reply mirror chunking, empty guard, mirror_enabled=False, send-raises resilience - tests/test_voice_session_channel_ids.py: new — split-attr contract + metrics payload schema - tests/test_voice_session_cleanup.py: update for new kwargs Plan: /home/moltbot/.claude/plans/vreau-ca-tot-textul-greedy-rivest.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
85 lines
2.8 KiB
Python
85 lines
2.8 KiB
Python
"""VoiceSession now accepts text_channel_id and voice_channel_id separately.
|
|
|
|
Locks in the public contract from the voice/text unify plan: the two ids
|
|
are stored as distinct attributes and both appear in the metrics payload
|
|
under their own keys (claude_session_key + voice_channel_id).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from src.voice import pipeline as pipeline_mod
|
|
from src.voice.pipeline import VoiceSession
|
|
|
|
|
|
def _make_session(text_id: int, voice_id: int) -> VoiceSession:
|
|
return VoiceSession(
|
|
text_channel_id=text_id,
|
|
voice_channel_id=voice_id,
|
|
guild_id=42,
|
|
voice_client=MagicMock(name="voice_client"),
|
|
bot=MagicMock(name="bot"),
|
|
ttsq=MagicMock(name="ttsq"),
|
|
whitelist=set(),
|
|
record_enabled=False,
|
|
mirror_enabled=True,
|
|
transcripts_jsonl_path=None,
|
|
loop=None,
|
|
router_route_message=MagicMock(name="route_message"),
|
|
)
|
|
|
|
|
|
def test_constructor_stores_separate_channel_ids():
|
|
session = _make_session(1001, 2002)
|
|
assert session.text_channel_id == 1001
|
|
assert session.voice_channel_id == 2002
|
|
assert session.text_channel_id != session.voice_channel_id
|
|
|
|
|
|
def test_constructor_rejects_legacy_channel_id_kwarg():
|
|
with pytest.raises(TypeError):
|
|
VoiceSession(
|
|
channel_id=1001, # legacy single id no longer accepted
|
|
voice_channel_id=2002,
|
|
guild_id=42,
|
|
voice_client=MagicMock(),
|
|
bot=MagicMock(),
|
|
ttsq=MagicMock(),
|
|
)
|
|
|
|
|
|
def test_metric_payload_contains_both_ids(tmp_path: Path, monkeypatch):
|
|
metrics_file = tmp_path / "voice_metrics.jsonl"
|
|
monkeypatch.setattr(pipeline_mod, "LOGS_DIR", tmp_path)
|
|
monkeypatch.setattr(pipeline_mod, "VOICE_METRICS_PATH", metrics_file)
|
|
|
|
session = _make_session(1001, 2002)
|
|
session._log_metric({"event": "test_event", "extra": "x"})
|
|
|
|
lines = metrics_file.read_text(encoding="utf-8").splitlines()
|
|
assert len(lines) == 1
|
|
event = json.loads(lines[0])
|
|
assert event["claude_session_key"] == "1001"
|
|
assert event["voice_channel_id"] == 2002
|
|
assert event["event"] == "test_event"
|
|
assert event["extra"] == "x"
|
|
assert "channel_id" not in event
|
|
|
|
|
|
def test_metric_keys_are_distinct():
|
|
# Same numeric id for both must still serialize as two separate keys.
|
|
session = _make_session(5555, 5555)
|
|
payload = {
|
|
"ts": 0.0,
|
|
"claude_session_key": str(session.text_channel_id),
|
|
"voice_channel_id": session.voice_channel_id,
|
|
}
|
|
assert payload["claude_session_key"] == "5555"
|
|
assert payload["voice_channel_id"] == 5555
|
|
assert isinstance(payload["claude_session_key"], str)
|
|
assert isinstance(payload["voice_channel_id"], int)
|