feat: add QR login and browser-backed web tools

This commit is contained in:
Azimkin
2026-09-14 14:19:20 +02:00
parent 3a8c198caa
commit b68e715267
19 changed files with 2740 additions and 8 deletions

View File

@@ -11,6 +11,7 @@ from app.agentic import (
AgentSessionStore,
AgentSyntaxError,
_build_context_messages,
_create_llm,
_download_message_media,
render_prompt_template,
parse_agent_invocation,
@@ -22,6 +23,26 @@ 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,
):
_create_llm(provider, parse_agent_invocation("prompt", AiConfig()))
_create_llm(provider, parse_agent_invocation(".ds 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",
)
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"
@@ -168,9 +189,11 @@ class AgentContextTests(unittest.IsolatedAsyncioTestCase):
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=[])),
):
brave_tools.return_value.tools.return_value = []
web_page_tools.return_value.tools.return_value = []
result = await run_agent_turn(
SimpleNamespace(),
command,
@@ -188,6 +211,7 @@ class AgentContextTests(unittest.IsolatedAsyncioTestCase):
)
self.assertTrue(aggregator_class.call_args.kwargs["thinking"])
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:

View File

@@ -45,6 +45,8 @@ class ConfigAiTests(unittest.TestCase):
{
"glm": {"api_key": "glm-key", "model": "glm-model"},
"deepseek": {"api_key": "ds-key", "model": "ds-model"},
"web_page_timeout_seconds": 20,
"web_page_max_chars": 12345,
}
)
@@ -52,6 +54,8 @@ class ConfigAiTests(unittest.TestCase):
self.assertEqual(config.glm.model, "glm-model")
self.assertEqual(config.deepseek.api_key, "ds-key")
self.assertEqual(config.deepseek.model, "ds-model")
self.assertEqual(config.web_page_timeout_seconds, 20)
self.assertEqual(config.web_page_max_chars, 12345)
def test_ai_provider_env_overrides(self) -> None:
env = {
@@ -59,6 +63,8 @@ class ConfigAiTests(unittest.TestCase):
"GLM_MODEL": "env-glm-model",
"DEEPSEEK_API_KEY": "env-ds",
"DEEPSEEK_MODEL": "env-ds-model",
"AI_WEB_PAGE_TIMEOUT_SECONDS": "30",
"AI_WEB_PAGE_MAX_CHARS": "54321",
}
with patch.dict("os.environ", env, clear=True):
config = _parse_ai_config({})
@@ -67,6 +73,8 @@ class ConfigAiTests(unittest.TestCase):
self.assertEqual(config.glm.model, "env-glm-model")
self.assertEqual(config.deepseek.api_key, "env-ds")
self.assertEqual(config.deepseek.model, "env-ds-model")
self.assertEqual(config.web_page_timeout_seconds, 30)
self.assertEqual(config.web_page_max_chars, 54321)
if __name__ == "__main__":

View File

@@ -21,6 +21,7 @@ session_dir = "sessions"
[[accounts]]
name = "personal"
session = "personal"
use_qr = true
[elevenlabs]
api_key = "test-key"
@@ -34,6 +35,7 @@ model_id = "scribe_v2"
self.assertEqual(config.api_id, 123456)
self.assertEqual(config.accounts[0].session, "personal")
self.assertTrue(config.accounts[0].use_qr)
self.assertEqual(config.elevenlabs.api_key, "test-key")
self.assertEqual(config.session_dir, root / "sessions")
self.assertEqual(config.ai.prompt_dir, root / "prompts")

101
tests/test_llm.py Normal file
View File

@@ -0,0 +1,101 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock
from micro_agents import Conversation, LlmMessage, Tool, ToolCallMessage, ToolCallResponseMessage, UserMessage
from micro_agents.conversation import ReasoningMessage
from app.llm import ChatCompletionsLLM
class ChatCompletionsLlmTests(unittest.IsolatedAsyncioTestCase):
async def test_uses_chat_completions_with_system_prompt_and_thinking(self) -> None:
response_message = SimpleNamespace(
content="done",
reasoning_content="reasoning",
tool_calls=None,
)
create = AsyncMock(
return_value=SimpleNamespace(
choices=[SimpleNamespace(message=response_message)],
)
)
llm = ChatCompletionsLLM("https://glm.example/v4", "key", "glm-model")
llm._ai = SimpleNamespace( # type: ignore[assignment]
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
)
result = await llm.ask(
Conversation([UserMessage("hello")]),
[],
instructions="system prompt",
thinking=True,
)
request = create.await_args.kwargs
self.assertEqual(request["model"], "glm-model")
self.assertEqual(
request["messages"],
[
{"role": "system", "content": "system prompt"},
{"role": "user", "content": "hello"},
],
)
self.assertEqual(request["extra_body"], {"thinking": {"type": "enabled"}})
self.assertIsInstance(result[0], ReasoningMessage)
self.assertIsInstance(result[1], LlmMessage)
async def test_serializes_and_parses_tool_calls(self) -> None:
model_call = SimpleNamespace(
id="call-2",
function=SimpleNamespace(name="lookup", arguments='{"query":"news"}'),
)
create = AsyncMock(
return_value=SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(
content="",
reasoning_content=None,
tool_calls=[model_call],
)
)
],
)
)
llm = ChatCompletionsLLM("https://glm.example/v4", "key", "glm-model")
llm._ai = SimpleNamespace( # type: ignore[assignment]
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
)
def lookup(query: str) -> str:
"""Look up a query."""
return query
conversation = Conversation(
[
UserMessage("find it"),
ReasoningMessage("need a tool"),
LlmMessage("checking"),
ToolCallMessage("call-1", "lookup", {"query": "weather"}),
ToolCallResponseMessage("call-1", "sunny"),
]
)
result = await llm.ask(conversation, [Tool(lookup)], thinking=False)
request = create.await_args.kwargs
self.assertEqual(request["extra_body"], {"thinking": {"type": "disabled"}})
self.assertEqual(request["tools"][0]["type"], "function")
self.assertEqual(request["tools"][0]["function"]["name"], "lookup")
self.assertEqual(request["messages"][1]["role"], "assistant")
self.assertEqual(request["messages"][1]["reasoning_content"], "need a tool")
self.assertEqual(request["messages"][2]["role"], "tool")
self.assertIsInstance(result[0], ToolCallMessage)
self.assertEqual(result[0].tool_name(), "lookup")
self.assertEqual(result[0].arguments(), {"query": "news"})
if __name__ == "__main__":
unittest.main()

37
tests/test_manager_qr.py Normal file
View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from app.config import AccountConfig, AppConfig
from app.manager import AccountManager
class AccountManagerQrTests(unittest.IsolatedAsyncioTestCase):
async def test_qr_flag_is_passed_when_starting_account(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
config = AppConfig(
api_id=123456,
api_hash="hash",
session_dir=Path(temp_dir),
accounts=[AccountConfig(name="personal", session="personal", use_qr=True)],
)
manager = AccountManager(config)
client = MagicMock()
client.start = AsyncMock()
client.get_me = AsyncMock(return_value=MagicMock(id=42))
with (
patch.object(AccountManager, "_make_client", return_value=client),
patch("app.manager.register_common_handlers"),
):
await manager.start_account(manager.states[0])
client.start.assert_awaited_once_with(use_qr=True)
self.assertEqual(manager.states[0].status, "running")
if __name__ == "__main__":
unittest.main()

81
tests/test_web_tools.py Normal file
View File

@@ -0,0 +1,81 @@
from __future__ import annotations
import json
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from app.web_tools import WebPageTools, _ensure_public_url
class WebPageToolsTests(unittest.IsolatedAsyncioTestCase):
async def test_private_network_urls_are_rejected(self) -> None:
for url in (
"http://127.0.0.1/admin",
"http://10.0.0.1/",
"http://169.254.169.254/latest/meta-data/",
"http://[::1]/",
):
with self.subTest(url=url):
with self.assertRaisesRegex(ValueError, "private network"):
await _ensure_public_url(url)
async def test_reads_page_as_bounded_markdown(self) -> None:
crawl_result = SimpleNamespace(
success=True,
url="https://example.com/article",
redirected_url=None,
status_code=200,
metadata={"title": "Example"},
markdown="abcdefghij",
)
with (
patch(
"app.web_tools._ensure_public_url",
AsyncMock(side_effect=lambda value: value),
) as ensure_public,
patch(
"app.web_tools._crawl_with_playwright",
AsyncMock(return_value=crawl_result),
) as crawl,
):
payload = json.loads(
await WebPageTools(timeout_seconds=12, max_chars=5).read_web_page(
"https://example.com/article"
)
)
self.assertEqual(payload["markdown"], "abcde")
self.assertTrue(payload["truncated"])
self.assertEqual(payload["title"], "Example")
self.assertEqual(ensure_public.await_count, 2)
crawl.assert_awaited_once_with("https://example.com/article", 12)
async def test_crawl_failure_is_reported_to_agent(self) -> None:
crawl_result = SimpleNamespace(
success=False,
url="https://example.com",
redirected_url=None,
error_message="blocked",
)
with (
patch(
"app.web_tools._ensure_public_url",
AsyncMock(side_effect=lambda value: value),
),
patch(
"app.web_tools._crawl_with_playwright",
AsyncMock(return_value=crawl_result),
),
):
with self.assertRaisesRegex(RuntimeError, "blocked"):
await WebPageTools().read_web_page("https://example.com")
def test_tool_is_registered_for_all_agent_sessions(self) -> None:
tools = WebPageTools().tools()
self.assertEqual([tool.name for tool in tools], ["read_web_page"])
if __name__ == "__main__":
unittest.main()