mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-31 03:12:16 +00:00
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>
319 lines
11 KiB
Python
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"]
|