fix(channels): add IM session reset commands

Closes #371.

- Add channel-agnostic /new, /reset, and /newsession handling in ChannelRuntime so IM users can start a fresh session without leaving the chat.
- Preserve existing session history while clearing only the channel session mapping.
- Document in-chat slash commands across README locales.
- Normalize pairing slash-command parsing so the documented case-insensitive command behavior matches runtime behavior.

Validation:
- PYTHONPATH=agent pytest agent/tests/test_channels_runtime.py -q
- python -m compileall -q agent/src/channels/runtime.py agent/src/channels/pairing/store.py agent/tests/test_channels_runtime.py
- git diff --check
- GitHub Actions CI: test
This commit is contained in:
shadowinlife authored and GitHub committed 2026-07-02 14:45:09 +08:00
1 parent 4709f4c33c
commit 6ec6bd1314
8 files changed
+287 -4

No files matched your search

+11
View File
@@ -684,6 +684,17 @@ vibe-trading channels pairing --channel telegram list
The built-in adapters cover `websocket`, `telegram`, `slack`, `discord`, `matrix`, `whatsapp`, `signal`, `qq`, `napcat`, `weixin`, `wecom`, `feishu`, `dingtalk`, `msteams`, `email`, and `mochat`. Use narrow extras such as `pip install "vibe-trading-ai[telegram]"`, or install the full channel set with `pip install "vibe-trading-ai[channels]"`. The built-in adapters cover `websocket`, `telegram`, `slack`, `discord`, `matrix`, `whatsapp`, `signal`, `qq`, `napcat`, `weixin`, `wecom`, `feishu`, `dingtalk`, `msteams`, `email`, and `mochat`. Use narrow extras such as `pip install "vibe-trading-ai[telegram]"`, or install the full channel set with `pip install "vibe-trading-ai[channels]"`.
**In-chat slash commands** (channel-agnostic, work in all 16 adapters):
| Command | Description |
|---------|-------------|
| `/new` | Reset the current session — the next message starts a fresh conversation |
| `/reset` | Alias for `/new` |
| `/newsession` | Alias for `/new` |
| `/pairing list` | Show pending sender-pairing requests |
Commands are case-insensitive and must be sent as the entire message (e.g. `hello /new` is treated as a regular message, not a reset).
</details> </details>
--- ---
+11
View File
@@ -674,6 +674,17 @@ vibe-trading channels pairing --channel telegram list
تشمل المحولات المدمجة `websocket` و`telegram` و`slack` و`discord` و`matrix` و`whatsapp` و`signal` و`qq` و`napcat` و`weixin` و`wecom` و`feishu` و`dingtalk` و`msteams` و`email` و`mochat`. يمكنك تثبيت منصة محددة مثل `pip install "vibe-trading-ai[telegram]"` أو تثبيت المجموعة كاملة عبر `pip install "vibe-trading-ai[channels]"`. تشمل المحولات المدمجة `websocket` و`telegram` و`slack` و`discord` و`matrix` و`whatsapp` و`signal` و`qq` و`napcat` و`weixin` و`wecom` و`feishu` و`dingtalk` و`msteams` و`email` و`mochat`. يمكنك تثبيت منصة محددة مثل `pip install "vibe-trading-ai[telegram]"` أو تثبيت المجموعة كاملة عبر `pip install "vibe-trading-ai[channels]"`.
**أوامر الشرطة داخل المحادثة** (مستقلة عن القناة، تعمل في جميع المحولات الـ 16):
| الأمر | الوصف |
|-------|-------|
| `/new` | إعادة تعيين الجلسة الحالية — الرسالة التالية تبدأ محادثة جديدة |
| `/reset` | اسم مستعار لـ `/new` |
| `/newsession` | اسم مستعار لـ `/new` |
| `/pairing list` | عرض طلبات sender pairing المعلقة |
الأوامر لا تحس بحالة الأحرف ويجب إرسالها كرسالة كاملة (مثلاً `hello /new` تُعامل كرسالة عادية وليس كأمر إعادة تعيين).
</details> </details>
--- ---
+11
View File
@@ -671,6 +671,17 @@ vibe-trading channels pairing --channel telegram list
Built-in adapters は `websocket`、`telegram`、`slack`、`discord`、`matrix`、`whatsapp`、`signal`、`qq`、`napcat`、`weixin`、`wecom`、`feishu`、`dingtalk`、`msteams`、`email`、`mochat` です。個別に `pip install "vibe-trading-ai[telegram]"` を使うか、全チャンネル分を `pip install "vibe-trading-ai[channels]"` で入れられます。 Built-in adapters は `websocket`、`telegram`、`slack`、`discord`、`matrix`、`whatsapp`、`signal`、`qq`、`napcat`、`weixin`、`wecom`、`feishu`、`dingtalk`、`msteams`、`email`、`mochat` です。個別に `pip install "vibe-trading-ai[telegram]"` を使うか、全チャンネル分を `pip install "vibe-trading-ai[channels]"` で入れられます。
**チャット内スラッシュコマンド**(チャンネル非依存、全 16 adapter で共通):
| コマンド | 説明 |
|---------|------|
| `/new` | 現在のセッションをリセット——次のメッセージで新しい会話を開始 |
| `/reset` | `/new` のエイリアス |
| `/newsession` | `/new` のエイリアス |
| `/pairing list` | 保留中の sender pairing リクエストを表示 |
コマンドは大文字小文字を区別せず、メッセージ全体として送信する必要があります(例:`hello /new` はリセットではなく通常メッセージとして処理されます)。
</details> </details>
--- ---
+11
View File
@@ -674,6 +674,17 @@ vibe-trading channels pairing --channel telegram list
Built-in adapters는 `websocket`, `telegram`, `slack`, `discord`, `matrix`, `whatsapp`, `signal`, `qq`, `napcat`, `weixin`, `wecom`, `feishu`, `dingtalk`, `msteams`, `email`, `mochat`입니다. 개별 플랫폼은 `pip install "vibe-trading-ai[telegram]"`처럼 설치하거나, 전체 채널 세트는 `pip install "vibe-trading-ai[channels]"`로 설치할 수 있습니다. Built-in adapters는 `websocket`, `telegram`, `slack`, `discord`, `matrix`, `whatsapp`, `signal`, `qq`, `napcat`, `weixin`, `wecom`, `feishu`, `dingtalk`, `msteams`, `email`, `mochat`입니다. 개별 플랫폼은 `pip install "vibe-trading-ai[telegram]"`처럼 설치하거나, 전체 채널 세트는 `pip install "vibe-trading-ai[channels]"`로 설치할 수 있습니다.
**채팅 내 슬래시 명령어** (채널 무관, 16개 어댑터 모두 공통):
| 명령어 | 설명 |
|--------|------|
| `/new` | 현재 세션 초기화 — 다음 메시지에서 새 대화 시작 |
| `/reset` | `/new`의 별칭 |
| `/newsession` | `/new`의 별칭 |
| `/pairing list` | 대기 중인 sender pairing 요청 표시 |
명령어는 대소문자를 구분하지 않으며, 전체 메시지로 전송해야 합니다 (예: `hello /new`은 초기화가 아닌 일반 메시지로 처리됩니다).
</details> </details>
--- ---
+11
View File
@@ -668,6 +668,17 @@ vibe-trading channels pairing --channel telegram list
内置适配器包括 `websocket`、`telegram`、`slack`、`discord`、`matrix`、`whatsapp`、`signal`、`qq`、`napcat`、`weixin`、`wecom`、`feishu`、`dingtalk`、`msteams`、`email` 和 `mochat`。可按需安装单个平台,例如 `pip install "vibe-trading-ai[telegram]"`,也可以一次安装全量通道依赖:`pip install "vibe-trading-ai[channels]"`。 内置适配器包括 `websocket`、`telegram`、`slack`、`discord`、`matrix`、`whatsapp`、`signal`、`qq`、`napcat`、`weixin`、`wecom`、`feishu`、`dingtalk`、`msteams`、`email` 和 `mochat`。可按需安装单个平台,例如 `pip install "vibe-trading-ai[telegram]"`,也可以一次安装全量通道依赖:`pip install "vibe-trading-ai[channels]"`。
**聊天内斜杠命令**(通道无关,全部 16 个适配器通用):
| 命令 | 说明 |
|------|------|
| `/new` | 重置当前会话——下一条消息将开启一段新对话 |
| `/reset` | `/new` 的别名 |
| `/newsession` | `/new` 的别名 |
| `/pairing list` | 显示待处理的 sender pairing 请求 |
命令不区分大小写,且必须作为整条消息发送(例如 `hello /new` 会被当作普通消息而非重置命令)。
</details> </details>
--- ---
+1 -1
View File
@@ -210,7 +210,7 @@ def handle_pairing_command(channel: str, subcommand_text: str) -> str:
so it can be used from both the CLI and the agent CommandRouter. so it can be used from both the CLI and the agent CommandRouter.
""" """
parts = subcommand_text.split() parts = subcommand_text.split()
sub = parts[0] if parts else "list" sub = parts[0].lower() if parts else "list"
arg = parts[1] if len(parts) > 1 else None arg = parts[1] if len(parts) > 1 else None
if sub in ("list",): if sub in ("list",):
+43 -2
View File
@@ -108,7 +108,7 @@ class ChannelRuntime:
async def _handle_inbound(self, msg: InboundMessage) -> None: async def _handle_inbound(self, msg: InboundMessage) -> None:
try: try:
if self._is_pairing_command(msg.content): if self._is_pairing_command(msg.content):
reply = handle_pairing_command(msg.channel, msg.content.split(None, 1)[1] if " " in msg.content else "list") reply = handle_pairing_command(msg.channel, self._pairing_subcommand_text(msg.content))
await self.bus.publish_outbound( await self.bus.publish_outbound(
OutboundMessage( OutboundMessage(
channel=msg.channel, channel=msg.channel,
@@ -119,6 +119,22 @@ class ChannelRuntime:
) )
return return
if self._is_new_session_command(msg.content):
old_id = self.reset_session(msg.session_key)
if old_id:
reply = "✅ Session reset. Your next message will start a new conversation."
else:
reply = "ℹ️ No active session to reset."
await self.bus.publish_outbound(
OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=reply,
metadata={"_channel_runtime": True, "session_reset": True},
)
)
return
session_id = self._session_for(msg) session_id = self._session_for(msg)
result = await self.session_service.send_message( result = await self.session_service.send_message(
session_id, session_id,
@@ -202,9 +218,34 @@ class ChannelRuntime:
tmp.write_text(json.dumps(self._session_map, indent=2, ensure_ascii=False), encoding="utf-8") tmp.write_text(json.dumps(self._session_map, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(self.session_map_path) tmp.replace(self.session_map_path)
def reset_session(self, session_key: str) -> str | None:
"""Remove a session mapping so the next message creates a fresh session.
Args:
session_key: The channel:chat_id key to reset.
Returns:
The removed session_id, or None if no mapping existed.
"""
removed = self._session_map.pop(session_key, None)
if removed is not None:
self._save_session_map()
return removed
@staticmethod @staticmethod
def _is_pairing_command(content: str) -> bool: def _is_pairing_command(content: str) -> bool:
return content.strip() == "/pairing" or content.strip().startswith("/pairing ") stripped = content.strip().lower()
return stripped == "/pairing" or stripped.startswith("/pairing ")
@staticmethod
def _pairing_subcommand_text(content: str) -> str:
parts = content.strip().split(None, 1)
return parts[1] if len(parts) > 1 else "list"
@staticmethod
def _is_new_session_command(content: str) -> bool:
"""Check if the message is a session reset command (/new, /reset, /newsession)."""
return content.strip().lower() in ("/new", "/reset", "/newsession")
def _session_id(session: Session | dict[str, Any] | Any) -> str: def _session_id(session: Session | dict[str, Any] | Any) -> str:
+188 -1
View File
@@ -288,7 +288,7 @@ def test_channel_runtime_handles_pairing_commands_without_agent(tmp_path: Path,
channel="telegram", channel="telegram",
sender_id="owner", sender_id="owner",
chat_id="chat-1", chat_id="chat-1",
content="/pairing list", content="/PAIRING LIST",
) )
) )
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=1) outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=1)
@@ -302,3 +302,190 @@ def test_channel_runtime_handles_pairing_commands_without_agent(tmp_path: Path,
assert outbound.metadata["_pairing_command"] is True assert outbound.metadata["_pairing_command"] is True
asyncio.run(scenario()) asyncio.run(scenario())
def test_channel_runtime_new_command_resets_session_and_creates_fresh_one(tmp_path: Path) -> None:
async def scenario() -> None:
from src.channels.runtime import ChannelRuntime
bus = MessageBus()
service = FakeSessionService()
runtime = ChannelRuntime(
bus=bus,
session_service=service,
manager=None,
session_map_path=tmp_path / "channel_sessions.json",
reply_timeout_s=1,
poll_interval_s=0.01,
)
await runtime.start(start_manager=False)
try:
await bus.publish_inbound(
InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="hello")
)
reply1 = await asyncio.wait_for(bus.consume_outbound(), timeout=1)
assert reply1.metadata["session_id"] == "session-1"
await bus.publish_inbound(
InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="/new")
)
reset_reply = await asyncio.wait_for(bus.consume_outbound(), timeout=1)
assert "Session reset" in reset_reply.content
assert reset_reply.metadata.get("session_reset") is True
await bus.publish_inbound(
InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="after reset")
)
reply2 = await asyncio.wait_for(bus.consume_outbound(), timeout=1)
assert reply2.metadata["session_id"] == "session-2"
finally:
await runtime.stop()
assert service.sent == [("session-1", "hello"), ("session-2", "after reset")]
asyncio.run(scenario())
def test_channel_runtime_new_command_with_no_existing_session(tmp_path: Path) -> None:
async def scenario() -> None:
from src.channels.runtime import ChannelRuntime
bus = MessageBus()
service = FakeSessionService()
runtime = ChannelRuntime(
bus=bus,
session_service=service,
manager=None,
session_map_path=tmp_path / "channel_sessions.json",
reply_timeout_s=1,
poll_interval_s=0.01,
)
await runtime.start(start_manager=False)
try:
await bus.publish_inbound(
InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/new")
)
reply = await asyncio.wait_for(bus.consume_outbound(), timeout=1)
finally:
await runtime.stop()
assert "No active session to reset" in reply.content
assert reply.metadata.get("session_reset") is True
assert service.sent == []
assert len(service.created) == 0
asyncio.run(scenario())
def test_channel_runtime_reset_and_newsession_aliases_work(tmp_path: Path) -> None:
async def scenario() -> None:
from src.channels.runtime import ChannelRuntime
bus = MessageBus()
service = FakeSessionService()
runtime = ChannelRuntime(
bus=bus,
session_service=service,
manager=None,
session_map_path=tmp_path / "channel_sessions.json",
reply_timeout_s=1,
poll_interval_s=0.01,
)
await runtime.start(start_manager=False)
try:
await bus.publish_inbound(
InboundMessage(channel="discord", sender_id="u1", chat_id="c1", content="hi")
)
await asyncio.wait_for(bus.consume_outbound(), timeout=1)
await bus.publish_inbound(
InboundMessage(channel="discord", sender_id="u1", chat_id="c1", content="/reset")
)
reply = await asyncio.wait_for(bus.consume_outbound(), timeout=1)
assert "Session reset" in reply.content
await bus.publish_inbound(
InboundMessage(channel="discord", sender_id="u1", chat_id="c1", content="hi again")
)
await asyncio.wait_for(bus.consume_outbound(), timeout=1)
await bus.publish_inbound(
InboundMessage(channel="discord", sender_id="u1", chat_id="c1", content="/newsession")
)
reply2 = await asyncio.wait_for(bus.consume_outbound(), timeout=1)
assert "Session reset" in reply2.content
finally:
await runtime.stop()
asyncio.run(scenario())
def test_channel_runtime_regular_messages_not_intercepted_as_new_session(tmp_path: Path) -> None:
async def scenario() -> None:
from src.channels.runtime import ChannelRuntime
bus = MessageBus()
service = FakeSessionService()
runtime = ChannelRuntime(
bus=bus,
session_service=service,
manager=None,
session_map_path=tmp_path / "channel_sessions.json",
reply_timeout_s=1,
poll_interval_s=0.01,
)
await runtime.start(start_manager=False)
try:
for text in ["hello /new world", "/new stuff", "/NEW YORK", "type /new to reset"]:
await bus.publish_inbound(
InboundMessage(channel="slack", sender_id="u1", chat_id="c1", content=text)
)
reply = await asyncio.wait_for(bus.consume_outbound(), timeout=1)
assert reply.metadata.get("session_reset") is not True
assert "agent reply:" in reply.content
finally:
await runtime.stop()
assert len(service.sent) == 4
asyncio.run(scenario())
def test_channel_runtime_session_map_persisted_after_reset(tmp_path: Path) -> None:
import json
async def scenario() -> None:
from src.channels.runtime import ChannelRuntime
map_path = tmp_path / "channel_sessions.json"
bus = MessageBus()
service = FakeSessionService()
runtime = ChannelRuntime(
bus=bus,
session_service=service,
manager=None,
session_map_path=map_path,
reply_timeout_s=1,
poll_interval_s=0.01,
)
await runtime.start(start_manager=False)
try:
await bus.publish_inbound(
InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="hi")
)
await asyncio.wait_for(bus.consume_outbound(), timeout=1)
data = json.loads(map_path.read_text(encoding="utf-8"))
assert data["feishu:c1"] == "session-1"
await bus.publish_inbound(
InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="/new")
)
await asyncio.wait_for(bus.consume_outbound(), timeout=1)
data = json.loads(map_path.read_text(encoding="utf-8"))
assert "feishu:c1" not in data
finally:
await runtime.stop()
asyncio.run(scenario())