Files
OpenJarvis/tests/agents/test_channel_agent.py
T
945dabbce7 fix(channels): use conversation_id (not channel type) as Discord reply destination (#495)
Closes #459. Reported by @jasonftl with a precise smoking gun: incoming ChannelMessage has channel="discord" (TYPE label) and conversation_id=<numeric channel id>, but the reply code was using cm.channel as the destination — Discord saw /channels/discord/messages and 404'd silently, blackholing every reply.

The bug was two field-mappings in channel_agent.py:_process_message (the happy path + the exception path):

  self._channel.send(
      msg.channel,                          # WRONG — TYPE label
      reply,
      conversation_id=msg.conversation_id,  # WRONG — channel id used as msg-ref-id
  )

The existing DiscordChannel.send() contract — proved by the existing test_send_with_conversation_id test — is:
  - first positional `channel` = native destination ID (Discord channel id)
  - `conversation_id` kwarg = native message ID for reply threading

Fix: swap both fields to the correct ones from ChannelMessage:

  self._channel.send(
      msg.conversation_id,                  # Discord channel id
      reply,
      conversation_id=msg.message_id,       # message id for threading
  )

Plus a defensive guard in discord_channel.py: when channel is empty, refuse fast with a clear warning instead of POSTing to /channels//messages and silently 404'ing.

3 new tests, 38 affected pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 19:54:03 -07:00

319 lines
11 KiB
Python

"""Tests for ChannelAgent and classify_query."""
from __future__ import annotations
import time
from typing import Any, Dict, List
from unittest.mock import MagicMock
from openjarvis.agents._stubs import AgentResult
from openjarvis.agents.channel_agent import ChannelAgent, classify_query
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelMessage,
ChannelStatus,
)
# ---------------------------------------------------------------------------
# FakeChannel test helper
# ---------------------------------------------------------------------------
class FakeChannel(BaseChannel):
"""Minimal in-process channel for unit tests."""
channel_id = "fake"
def __init__(self) -> None:
self._handlers: List[ChannelHandler] = []
self._sent: List[Dict[str, Any]] = []
# BaseChannel abstract methods
def connect(self) -> None: # noqa: D102
pass
def disconnect(self) -> None: # noqa: D102
pass
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
self._sent.append(
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
}
)
return True
def status(self) -> ChannelStatus: # noqa: D102
return ChannelStatus.CONNECTED
def list_channels(self) -> List[str]: # noqa: D102
return ["fake"]
def on_message(self, handler: ChannelHandler) -> None: # noqa: D102
self._handlers.append(handler)
# Helper for tests
def simulate_message(self, msg: ChannelMessage) -> None:
"""Invoke all registered handlers with *msg*."""
for h in self._handlers:
h(msg)
# ---------------------------------------------------------------------------
# classify_query — 11 tests
# ---------------------------------------------------------------------------
class TestClassifyQuery:
def test_when_prefix_is_quick(self):
assert classify_query("When is my next meeting?") == "quick"
def test_where_prefix_is_quick(self):
assert classify_query("Where is the design doc?") == "quick"
def test_find_prefix_is_quick(self):
assert classify_query("Find the budget spreadsheet") == "quick"
def test_who_prefix_is_quick(self):
assert classify_query("Who sent the last email?") == "quick"
def test_summarize_is_deep(self):
assert classify_query("Summarize all discussions about pricing") == "deep"
def test_research_context_is_deep(self):
assert classify_query("Research the context behind the K8s decision") == "deep"
def test_context_keyword_is_deep(self):
assert classify_query("What was the context around the migration?") == "deep"
def test_time_range_is_deep(self):
assert classify_query("What happened last month with the project?") == "deep"
def test_long_query_is_deep(self):
long_q = (
"Can you help me understand what has been going on with the backend "
"services over the past few weeks and whether there are any patterns "
"that might indicate problems?"
)
assert len(long_q.split()) > 20
assert classify_query(long_q) == "deep"
def test_whats_prefix_is_quick(self):
assert classify_query("What's Sarah's email?") == "quick"
def test_compare_is_deep(self):
assert (
classify_query("Compare what Sarah and Mike said about the budget")
== "deep"
)
# ---------------------------------------------------------------------------
# ChannelAgent — 6 tests
# ---------------------------------------------------------------------------
def _make_agent(content: str = "Here is your answer.") -> MagicMock:
agent = MagicMock()
agent.run.return_value = AgentResult(content=content)
return agent
def _make_msg(
content: str = "When is my next meeting?",
channel: str = "fake",
sender: str = "user1",
session_id: str = "sess-001",
conversation_id: str = "",
message_id: str = "",
) -> ChannelMessage:
return ChannelMessage(
channel=channel,
sender=sender,
content=content,
session_id=session_id,
conversation_id=conversation_id,
message_id=message_id,
)
class TestChannelAgent:
def test_registers_handler_on_channel(self):
"""ChannelAgent must register exactly one handler via on_message."""
channel = FakeChannel()
agent = _make_agent()
ca = ChannelAgent(channel, agent)
assert len(channel._handlers) == 1
ca.shutdown()
def test_quick_query_sends_inline_response(self):
"""Quick query with short response → inline, no escalation link."""
channel = FakeChannel()
agent = _make_agent("Here is your answer.")
ca = ChannelAgent(channel, agent)
msg = _make_msg("When is my next meeting?")
channel.simulate_message(msg)
ca.shutdown() # waits for background task to complete
assert len(channel._sent) == 1
sent_content = channel._sent[0]["content"]
assert "openjarvis://research/" not in sent_content
assert "Here is your answer." in sent_content
def test_deep_query_sends_preview_and_escalation_link(self):
"""Deep query → preview truncated to 300 chars + openjarvis:// link."""
channel = FakeChannel()
agent = _make_agent("Deep analysis result.")
ca = ChannelAgent(channel, agent)
msg = _make_msg("Summarize all discussions about the pricing strategy")
channel.simulate_message(msg)
ca.shutdown()
assert len(channel._sent) == 1
sent_content = channel._sent[0]["content"]
assert "openjarvis://research/" in sent_content
assert "Full report ready" in sent_content
def test_agent_error_sends_friendly_message(self):
"""If agent.run() raises, a friendly error message is sent."""
channel = FakeChannel()
agent = MagicMock()
agent.run.side_effect = RuntimeError("model unavailable")
ca = ChannelAgent(channel, agent)
msg = _make_msg("Find the latest report")
channel.simulate_message(msg)
ca.shutdown()
assert len(channel._sent) == 1
sent_content = channel._sent[0]["content"]
assert "error" in sent_content.lower()
def test_quick_query_calls_agent_run(self):
"""agent.run() is called exactly once with the message content."""
channel = FakeChannel()
agent = _make_agent()
ca = ChannelAgent(channel, agent)
msg = _make_msg("Who sent the last email?")
channel.simulate_message(msg)
ca.shutdown()
agent.run.assert_called_once_with(msg.content)
def test_handler_does_not_block(self):
"""_handle_message must return in <0.5 s even if agent takes 2 s."""
import threading
channel = FakeChannel()
# Agent that sleeps 2 s
slow_agent = MagicMock()
barrier = threading.Event()
def _slow_run(text: str) -> AgentResult:
barrier.wait(timeout=5)
return AgentResult(content="done")
slow_agent.run.side_effect = _slow_run
ca = ChannelAgent(channel, slow_agent)
msg = _make_msg("When is my next meeting?")
t0 = time.monotonic()
channel.simulate_message(msg)
elapsed = time.monotonic() - t0
assert elapsed < 0.5, f"handler blocked for {elapsed:.2f}s"
# Unblock the background thread so shutdown doesn't hang forever
barrier.set()
ca.shutdown()
def test_long_response_triggers_escalation(self):
"""Even a quick query gets escalated when the response exceeds 500 chars."""
channel = FakeChannel()
long_response = "x" * 600
agent = _make_agent(long_response)
ca = ChannelAgent(channel, agent)
msg = _make_msg("When is my next meeting?")
channel.simulate_message(msg)
ca.shutdown()
assert len(channel._sent) == 1
sent_content = channel._sent[0]["content"]
assert "openjarvis://research/" in sent_content
def test_reply_uses_conversation_id_as_destination_not_channel_type(self):
"""Regression for #459 — Discord (and every per-adapter native API)
wants the per-channel destination ID, not the channel TYPE label.
Pre-fix:
self._channel.send(msg.channel, ...) # "discord" → 404
conversation_id=msg.conversation_id # channel-id used as msg-ref-id
Post-fix:
self._channel.send(msg.conversation_id, ...) # numeric channel id
conversation_id=msg.message_id # the message being replied to
"""
channel = FakeChannel()
agent = _make_agent("hi back")
ca = ChannelAgent(channel, agent)
# The exact ChannelMessage shape DiscordChannel actually produces:
# channel = "discord" (TYPE label), conversation_id = numeric Discord
# channel id, message_id = numeric Discord message id.
msg = _make_msg(
content="hello",
channel="discord",
conversation_id="987654321098765432",
message_id="111122223333444455",
)
channel.simulate_message(msg)
ca.shutdown()
assert len(channel._sent) == 1
sent = channel._sent[0]
# The destination must be the numeric channel id, not the TYPE label.
assert sent["channel"] == "987654321098765432"
assert sent["channel"] != "discord"
# The conversation_id kwarg must carry the message id (for reply
# threading), not the channel id.
assert sent["conversation_id"] == "111122223333444455"
def test_error_path_also_uses_conversation_id_as_destination(self):
"""The except-clause friendly-error path has the same field-mapping
bug as the success path (channel_agent.py:115-119). Verify both
sites are fixed."""
channel = FakeChannel()
agent = MagicMock()
agent.run.side_effect = RuntimeError("model unavailable")
ca = ChannelAgent(channel, agent)
msg = _make_msg(
content="hello",
channel="discord",
conversation_id="987654321098765432",
message_id="111122223333444455",
)
channel.simulate_message(msg)
ca.shutdown()
assert len(channel._sent) == 1
sent = channel._sent[0]
assert sent["channel"] == "987654321098765432"
assert sent["conversation_id"] == "111122223333444455"
assert "Sorry" in sent["content"]