OpenRouter is used when no provider flag is set. Claude is selected with .cl, and GLM stays available via .glm.
412 lines
15 KiB
Python
412 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
|
|
|
from app.agentic import (
|
|
AgentSessionHandle,
|
|
AgentSessionStore,
|
|
AgentSyntaxError,
|
|
_build_context_messages,
|
|
_create_llm,
|
|
_download_message_media,
|
|
_provider,
|
|
render_prompt_template,
|
|
parse_agent_invocation,
|
|
run_agent_turn,
|
|
strip_prompt_comments,
|
|
)
|
|
from app.commands.builtins import ai, command_registry
|
|
from app.config import AiConfig, AiProviderConfig
|
|
|
|
|
|
class AgentInvocationTests(unittest.TestCase):
|
|
def test_glm_uses_chat_completions_and_deepseek_uses_responses(self) -> None:
|
|
provider = AiProviderConfig("https://example.test", "key", "model")
|
|
with (
|
|
patch("app.agentic.ChatCompletionsLLM") as chat_llm,
|
|
patch("app.agentic.OpenAIBasedLLM") as responses_llm,
|
|
patch("app.agentic.AnthropicMessagesLLM") as claude_llm,
|
|
patch("app.agentic.OpenRouterChatLLM") as openrouter_llm,
|
|
):
|
|
_create_llm(provider, parse_agent_invocation(".glm prompt", AiConfig()))
|
|
_create_llm(provider, parse_agent_invocation(".ds prompt", AiConfig()))
|
|
_create_llm(provider, parse_agent_invocation(".cl prompt", AiConfig()))
|
|
_create_llm(provider, parse_agent_invocation("prompt", AiConfig()))
|
|
|
|
chat_llm.assert_called_once_with(
|
|
api_url="https://example.test",
|
|
api_key="key",
|
|
model="model",
|
|
)
|
|
responses_llm.assert_called_once_with(
|
|
api_url="https://example.test",
|
|
api_key="key",
|
|
model="model",
|
|
)
|
|
claude_llm.assert_called_once_with(
|
|
api_url="https://example.test",
|
|
api_key="key",
|
|
model="model",
|
|
)
|
|
openrouter_llm.assert_called_once_with(
|
|
api_url="https://example.test",
|
|
api_key="key",
|
|
model="model",
|
|
)
|
|
|
|
def test_prompt_comments_are_removed_without_breaking_urls(self) -> None:
|
|
value = strip_prompt_comments(
|
|
"// first\nkeep https://example.com // trailing\n/* block\ncomment */\nlast"
|
|
)
|
|
|
|
self.assertNotIn("first", value)
|
|
self.assertNotIn("trailing", value)
|
|
self.assertNotIn("block", value)
|
|
self.assertIn("https://example.com", value)
|
|
self.assertIn("last", value)
|
|
|
|
def test_prompt_template_replaces_nested_values_and_text(self) -> None:
|
|
rendered = render_prompt_template(
|
|
"caller={{caller_user.id}} owner={owner.name} text={{text}}",
|
|
{
|
|
"caller_user": {"id": 42},
|
|
"owner": {"name": "Owner"},
|
|
"text": "old",
|
|
"source_text": "new",
|
|
},
|
|
)
|
|
|
|
self.assertEqual(rendered, "caller=42 owner=Owner text=new")
|
|
|
|
def test_parses_context_provider_and_thinking_flags(self) -> None:
|
|
invocation = parse_agent_invocation("12 .ds .th explain this", AiConfig())
|
|
|
|
self.assertEqual(invocation.context_messages, 12)
|
|
self.assertTrue(invocation.use_deepseek)
|
|
self.assertFalse(invocation.use_claude)
|
|
self.assertFalse(invocation.use_openrouter)
|
|
self.assertTrue(invocation.thinking)
|
|
self.assertEqual(invocation.prompt, "explain this")
|
|
|
|
claude = parse_agent_invocation("3 .ds .cl .th summarize", AiConfig())
|
|
self.assertFalse(claude.use_deepseek)
|
|
self.assertTrue(claude.use_claude)
|
|
self.assertFalse(claude.use_openrouter)
|
|
self.assertTrue(claude.thinking)
|
|
self.assertEqual(claude.prompt, "summarize")
|
|
self.assertEqual(claude.context_messages, 3)
|
|
selected = _provider(
|
|
AiConfig(claude=AiProviderConfig("https://api.anthropic.com", "key", "claude-haiku-5-5")),
|
|
claude,
|
|
)
|
|
self.assertEqual(selected.model, "claude-haiku-5-5")
|
|
|
|
plain = parse_agent_invocation("just ask", AiConfig())
|
|
self.assertTrue(plain.use_openrouter)
|
|
self.assertFalse(plain.use_deepseek)
|
|
self.assertFalse(plain.use_claude)
|
|
self.assertEqual(
|
|
_provider(AiConfig(), plain).api_url,
|
|
"https://openrouter.ai/api/v1",
|
|
)
|
|
|
|
routed = parse_agent_invocation(".cl .or .th route this", AiConfig())
|
|
self.assertFalse(routed.use_claude)
|
|
self.assertTrue(routed.use_openrouter)
|
|
self.assertEqual(routed.prompt, "route this")
|
|
selected_router = _provider(
|
|
AiConfig(
|
|
openrouter=AiProviderConfig(
|
|
"https://openrouter.ai/api/v1",
|
|
"key",
|
|
"anthropic/claude-haiku-5.5",
|
|
)
|
|
),
|
|
routed,
|
|
)
|
|
self.assertEqual(selected_router.api_url, "https://openrouter.ai/api/v1")
|
|
self.assertEqual(selected_router.model, "anthropic/claude-haiku-5.5")
|
|
|
|
def test_ignores_unknown_leading_dot_arguments(self) -> None:
|
|
invocation = parse_agent_invocation(".unknown .ds prompt", AiConfig())
|
|
|
|
self.assertTrue(invocation.use_deepseek)
|
|
self.assertEqual(invocation.prompt, "prompt")
|
|
|
|
def test_rejects_missing_prompt(self) -> None:
|
|
with self.assertRaises(AgentSyntaxError):
|
|
parse_agent_invocation("10 .ds .th", AiConfig())
|
|
|
|
def test_rejects_context_count_above_configured_limit(self) -> None:
|
|
with self.assertRaises(AgentSyntaxError):
|
|
parse_agent_invocation("101 prompt", AiConfig(max_context_messages=100))
|
|
|
|
|
|
class AgentContextTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_reply_is_injected_once_and_history_is_chronological(self) -> None:
|
|
older = SimpleNamespace(id=1, chat=SimpleNamespace(id=99), text="older")
|
|
newer = SimpleNamespace(id=2, chat=SimpleNamespace(id=99), text="newer")
|
|
command = SimpleNamespace(
|
|
id=3,
|
|
chat=SimpleNamespace(id=99),
|
|
reply_to_message=older,
|
|
)
|
|
|
|
async def history():
|
|
yield newer
|
|
yield older
|
|
|
|
client = SimpleNamespace(get_chat_history=lambda *args, **kwargs: history())
|
|
invocation = parse_agent_invocation("2 prompt", AiConfig())
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
messages = await _build_context_messages(
|
|
client,
|
|
command,
|
|
invocation,
|
|
AiConfig(),
|
|
Path(temp_dir),
|
|
user_template="wrapped={{message.is_reply}} {{text}}",
|
|
template_context={},
|
|
)
|
|
|
|
self.assertEqual(len(messages), 2)
|
|
self.assertIn("wrapped=true", messages[0].message())
|
|
self.assertIn("older", messages[0].message())
|
|
self.assertIn("newer", messages[1].message())
|
|
|
|
async def test_media_over_limit_is_not_downloaded(self) -> None:
|
|
message = SimpleNamespace(
|
|
id=1,
|
|
document=SimpleNamespace(file_size=6, mime_type="text/plain"),
|
|
)
|
|
client = SimpleNamespace(download_media=AsyncMock())
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
media = await _download_message_media(client, message, Path(temp_dir), 5)
|
|
|
|
self.assertEqual(media, [])
|
|
client.download_media.assert_not_awaited()
|
|
|
|
async def test_media_within_limit_is_attached(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "note.txt"
|
|
path.write_bytes(b"hello")
|
|
message = SimpleNamespace(
|
|
id=1,
|
|
document=SimpleNamespace(
|
|
file_size=5,
|
|
mime_type="text/plain",
|
|
file_name="note.txt",
|
|
),
|
|
)
|
|
client = SimpleNamespace(download_media=AsyncMock(return_value=str(path)))
|
|
|
|
media = await _download_message_media(client, message, Path(temp_dir), 5)
|
|
|
|
self.assertEqual(len(media), 1)
|
|
self.assertEqual(media[0].data, b"hello")
|
|
self.assertEqual(media[0].kind, "file")
|
|
|
|
async def test_deepseek_and_reasoning_are_passed_to_agent(self) -> None:
|
|
config = AiConfig(
|
|
glm=AiProviderConfig("https://glm.example", "glm-key", "glm-model"),
|
|
deepseek=AiProviderConfig("https://ds.example", "ds-key", "ds-model"),
|
|
brave_api_key="brave-key",
|
|
)
|
|
invocation = parse_agent_invocation(".ds .th prompt", config)
|
|
command = SimpleNamespace(
|
|
id=3,
|
|
chat=SimpleNamespace(id=99),
|
|
from_user=SimpleNamespace(id=42),
|
|
reply_to_message=None,
|
|
photo=SimpleNamespace(file_size=100),
|
|
)
|
|
account = SimpleNamespace(
|
|
me=SimpleNamespace(id=7, full_name="Owner", username="owner"),
|
|
config=SimpleNamespace(name="personal", session="personal"),
|
|
)
|
|
final_message = SimpleNamespace(message=lambda: "answer")
|
|
session = SimpleNamespace(
|
|
run=AsyncMock(
|
|
return_value=SimpleNamespace(
|
|
succeeded=True,
|
|
final_message=final_message,
|
|
error=None,
|
|
)
|
|
)
|
|
)
|
|
agent = MagicMock()
|
|
agent.create_session.return_value = session
|
|
attached_media = MagicMock()
|
|
|
|
with (
|
|
patch("app.agentic.OpenAIBasedLLM") as llm_class,
|
|
patch("app.agentic.Aggregator", return_value=agent) as aggregator_class,
|
|
patch("app.agentic.BraveSearchTools") as brave_tools,
|
|
patch("app.agentic.WebPageTools") as web_page_tools,
|
|
patch("app.agentic._build_context_messages", AsyncMock(return_value=[])),
|
|
patch(
|
|
"app.agentic._download_message_media",
|
|
AsyncMock(return_value=[attached_media]),
|
|
),
|
|
):
|
|
brave_tools.return_value.tools.return_value = []
|
|
web_page_tools.return_value.tools.return_value = []
|
|
result = await run_agent_turn(
|
|
SimpleNamespace(),
|
|
command,
|
|
account,
|
|
AgentSessionStore(),
|
|
invocation,
|
|
config,
|
|
)
|
|
|
|
self.assertEqual(result.answer, "answer")
|
|
llm_class.assert_called_once_with(
|
|
api_url="https://ds.example",
|
|
api_key="ds-key",
|
|
model="ds-model",
|
|
)
|
|
self.assertTrue(aggregator_class.call_args.kwargs["thinking"])
|
|
session.run.assert_awaited_once_with("prompt", media=[attached_media])
|
|
brave_tools.assert_called_once_with(api_key="brave-key", timeout_seconds=10.0)
|
|
web_page_tools.assert_called_once_with(timeout_seconds=45.0, max_chars=60_000)
|
|
result.handle.close()
|
|
|
|
async def test_reply_to_ai_response_reuses_existing_session(self) -> None:
|
|
workspace = MagicMock()
|
|
final_message = SimpleNamespace(message=lambda: "continued")
|
|
session = SimpleNamespace(
|
|
context={"old": True},
|
|
run=AsyncMock(
|
|
return_value=SimpleNamespace(
|
|
succeeded=True,
|
|
final_message=final_message,
|
|
error=None,
|
|
)
|
|
),
|
|
)
|
|
handle = AgentSessionHandle(session, workspace, set())
|
|
store = AgentSessionStore()
|
|
ai_response = SimpleNamespace(id=50, chat=SimpleNamespace(id=99))
|
|
store.register_response(handle, ai_response)
|
|
command = SimpleNamespace(
|
|
id=51,
|
|
chat=SimpleNamespace(id=99),
|
|
from_user=SimpleNamespace(id=7, full_name="Owner"),
|
|
reply_to_message=ai_response,
|
|
)
|
|
account = SimpleNamespace(
|
|
me=SimpleNamespace(id=7, full_name="Owner"),
|
|
config=SimpleNamespace(name="personal", session="personal"),
|
|
)
|
|
|
|
result = await run_agent_turn(
|
|
SimpleNamespace(),
|
|
command,
|
|
account,
|
|
store,
|
|
parse_agent_invocation("continue", AiConfig()),
|
|
AiConfig(),
|
|
)
|
|
|
|
self.assertIs(result.handle, handle)
|
|
self.assertEqual(result.answer, "continued")
|
|
session.run.assert_awaited_once_with("continue")
|
|
self.assertTrue(session.context["is_owner"])
|
|
store.clear()
|
|
workspace.cleanup.assert_called_once()
|
|
|
|
|
|
class AiCommandTests(unittest.IsolatedAsyncioTestCase):
|
|
def _ctx(self, args: str = "prompt") -> SimpleNamespace:
|
|
sent = SimpleNamespace(id=124, chat=SimpleNamespace(id=99))
|
|
message = SimpleNamespace(
|
|
id=123,
|
|
react=AsyncMock(),
|
|
reply_text=AsyncMock(return_value=sent),
|
|
)
|
|
session_store = MagicMock()
|
|
return SimpleNamespace(
|
|
args=args,
|
|
message=message,
|
|
client=SimpleNamespace(),
|
|
account=SimpleNamespace(
|
|
me=SimpleNamespace(id=7, full_name="Owner"),
|
|
config=SimpleNamespace(name="personal", session="personal"),
|
|
ai_sessions=session_store,
|
|
),
|
|
manager=SimpleNamespace(config=SimpleNamespace(ai=AiConfig())),
|
|
reply=message.reply_text,
|
|
)
|
|
|
|
def test_ai_is_registered_as_shared(self) -> None:
|
|
definition = command_registry.resolve("ai")
|
|
|
|
self.assertIsNotNone(definition)
|
|
self.assertTrue(definition.is_shared)
|
|
self.assertEqual(definition.guest_prefix, "..")
|
|
|
|
async def test_success_replies_with_agent_answer(self) -> None:
|
|
ctx = self._ctx()
|
|
handle = MagicMock()
|
|
with patch(
|
|
"app.commands.builtins.run_agent_turn",
|
|
AsyncMock(return_value=SimpleNamespace(answer="answer", handle=handle)),
|
|
):
|
|
await ai(ctx)
|
|
|
|
ctx.reply.assert_awaited_once_with("answer")
|
|
ctx.account.ai_sessions.register_response.assert_called_once_with(
|
|
handle,
|
|
ctx.reply.return_value,
|
|
)
|
|
self.assertEqual(ctx.message.react.await_args_list, [call("🤔"), call(None)])
|
|
|
|
async def test_invalid_syntax_only_dislikes(self) -> None:
|
|
ctx = self._ctx(".ds .th")
|
|
|
|
with self.assertLogs("app.commands.builtins", level="ERROR"):
|
|
await ai(ctx)
|
|
|
|
self.assertEqual(ctx.message.react.await_args_list, [call("🤔"), call("👎")])
|
|
ctx.reply.assert_not_awaited()
|
|
|
|
async def test_agent_error_only_dislikes(self) -> None:
|
|
ctx = self._ctx()
|
|
with patch(
|
|
"app.commands.builtins.run_agent_turn",
|
|
AsyncMock(side_effect=RuntimeError("API failed")),
|
|
):
|
|
with self.assertLogs("app.commands.builtins", level="ERROR"):
|
|
await ai(ctx)
|
|
|
|
self.assertEqual(ctx.message.react.await_args_list, [call("🤔"), call("👎")])
|
|
ctx.reply.assert_not_awaited()
|
|
|
|
async def test_thinking_reaction_failure_does_not_stop_ai(self) -> None:
|
|
ctx = self._ctx()
|
|
ctx.message.react.side_effect = [RuntimeError("reactions disabled"), None]
|
|
|
|
with (
|
|
patch(
|
|
"app.commands.builtins.run_agent_turn",
|
|
AsyncMock(
|
|
return_value=SimpleNamespace(answer="answer", handle=MagicMock())
|
|
),
|
|
),
|
|
self.assertLogs("app.commands.builtins", level="ERROR"),
|
|
):
|
|
await ai(ctx)
|
|
|
|
ctx.reply.assert_awaited_once_with("answer")
|
|
self.assertEqual(ctx.message.react.await_args_list, [call("🤔"), call(None)])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|