Independent FastAPI backend for the MaskanX agentic growth platform. Includes the agent runtime, MCP client integrations (Meta Ads, LinkedIn, HubSpot, Tavily, Exa, xAI, Citedy, image generation), PostgreSQL storage for chats and cron jobs, provider and secret management, and the CLI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
323 lines
10 KiB
Python
323 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
from types import SimpleNamespace
|
|
|
|
|
|
def test_strip_missing_local_files_removes_file_blocks():
|
|
from adclaw.agents.model_factory import _strip_missing_local_files
|
|
|
|
msg = SimpleNamespace(
|
|
content=[
|
|
{"type": "text", "text": "keep"},
|
|
{
|
|
"type": "file",
|
|
"file_url": "file:///home/adclaw/.adclaw/media/telegram/missing.pptx",
|
|
},
|
|
],
|
|
get_text_content=lambda: "fallback",
|
|
)
|
|
|
|
_strip_missing_local_files([msg])
|
|
|
|
assert msg.content == [{"type": "text", "text": "keep"}]
|
|
|
|
|
|
def test_strip_missing_local_files_removes_nested_source_blocks():
|
|
from adclaw.agents.model_factory import _strip_missing_local_files
|
|
|
|
msg = SimpleNamespace(
|
|
content=[
|
|
{
|
|
"type": "image",
|
|
"source": {
|
|
"type": "url",
|
|
"url": "file:///home/adclaw/.adclaw/media/telegram/missing.jpg",
|
|
},
|
|
},
|
|
],
|
|
get_text_content=lambda: "",
|
|
)
|
|
|
|
_strip_missing_local_files([msg])
|
|
|
|
assert msg.content == "[local file removed]"
|
|
|
|
|
|
def test_strip_missing_local_files_keeps_remote_urls():
|
|
from adclaw.agents.model_factory import _strip_missing_local_files
|
|
|
|
block = {
|
|
"type": "image",
|
|
"source": {"type": "url", "url": "https://example.com/image.jpg"},
|
|
}
|
|
msg = SimpleNamespace(
|
|
content=[block],
|
|
get_text_content=lambda: "",
|
|
)
|
|
|
|
_strip_missing_local_files([msg])
|
|
|
|
assert msg.content == [block]
|
|
|
|
|
|
def test_strip_reasoning_blocks_drops_thinking_only_messages():
|
|
from adclaw.agents.model_factory import _strip_reasoning_blocks
|
|
|
|
thinking_only = SimpleNamespace(
|
|
content=[{"type": "thinking", "thinking": "private chain"}],
|
|
)
|
|
mixed = SimpleNamespace(
|
|
content=[
|
|
{"type": "thinking", "thinking": "private chain"},
|
|
{"type": "text", "text": "visible answer"},
|
|
],
|
|
)
|
|
|
|
messages = _strip_reasoning_blocks([thinking_only, mixed])
|
|
|
|
assert messages == [mixed]
|
|
assert mixed.content == [{"type": "text", "text": "visible answer"}]
|
|
|
|
|
|
def test_strip_bare_tool_call_text_messages_drops_json_only_assistant():
|
|
from adclaw.agents.model_factory import _strip_bare_tool_call_text_messages
|
|
|
|
raw_tool = SimpleNamespace(
|
|
role="assistant",
|
|
content='{"name":"agent.status","arguments":{}}',
|
|
)
|
|
normal_json = SimpleNamespace(
|
|
role="assistant",
|
|
content='{"campaign":"CEDAR-17"}',
|
|
)
|
|
user_msg = SimpleNamespace(
|
|
role="user",
|
|
content='{"name":"agent.status","arguments":{}}',
|
|
)
|
|
|
|
messages = _strip_bare_tool_call_text_messages(
|
|
[raw_tool, normal_json, user_msg],
|
|
)
|
|
|
|
assert messages == [normal_json, user_msg]
|
|
|
|
|
|
def test_bare_tool_call_detector_accepts_json_string_arguments():
|
|
from adclaw.agents.model_factory import is_bare_tool_call_json_text
|
|
|
|
assert is_bare_tool_call_json_text(
|
|
'{"name":"agent.status","arguments":"{\\"verbose\\":false}"}',
|
|
)
|
|
|
|
|
|
def test_normalize_assistant_tool_call_content_replaces_null():
|
|
from adclaw.agents.model_factory import (
|
|
_normalize_assistant_tool_call_content,
|
|
)
|
|
|
|
messages = [
|
|
{"role": "assistant", "content": None, "tool_calls": [{"id": "t1"}]},
|
|
{"role": "assistant", "content": None},
|
|
]
|
|
|
|
_normalize_assistant_tool_call_content(messages)
|
|
|
|
assert messages[0]["content"] == ""
|
|
assert messages[1]["content"] is None
|
|
|
|
|
|
def test_host_ai_remote_model_gets_safe_max_tokens(monkeypatch):
|
|
from adclaw.agents.model_factory import _create_remote_model_instance
|
|
|
|
class FakeChatModel:
|
|
def __init__(self, model_name, **kwargs):
|
|
self.model_name = model_name
|
|
self.kwargs = kwargs
|
|
|
|
monkeypatch.delenv("MASKANX_HOST_AI_MAX_OUTPUT_TOKENS", raising=False)
|
|
monkeypatch.delenv("MASKANX_HOST_AI_MAX_TOKENS", raising=False)
|
|
llm_cfg = SimpleNamespace(
|
|
provider_id="maskanx-host-ai",
|
|
model="@cf/google/gemma-4-26b-a4b-it",
|
|
api_key="ach_test",
|
|
base_url="https://real.maskanx.app/api/host-ai/v1",
|
|
)
|
|
|
|
model = _create_remote_model_instance(llm_cfg, FakeChatModel)
|
|
|
|
assert model.kwargs["generate_kwargs"] == {"max_tokens": 4096}
|
|
assert "reasoning_effort" not in model.kwargs
|
|
|
|
|
|
def test_host_ai_remote_model_uses_canonical_max_output_tokens(monkeypatch):
|
|
from adclaw.agents.model_factory import _create_remote_model_instance
|
|
|
|
class FakeChatModel:
|
|
def __init__(self, model_name, **kwargs):
|
|
self.model_name = model_name
|
|
self.kwargs = kwargs
|
|
|
|
monkeypatch.setenv("MASKANX_HOST_AI_MAX_OUTPUT_TOKENS", "321")
|
|
monkeypatch.setenv("MASKANX_HOST_AI_MAX_TOKENS", "654")
|
|
llm_cfg = SimpleNamespace(
|
|
provider_id="maskanx-host-ai",
|
|
model="@cf/google/gemma-4-26b-a4b-it",
|
|
api_key="ach_test",
|
|
base_url="https://real.maskanx.app/api/host-ai/v1",
|
|
)
|
|
|
|
model = _create_remote_model_instance(llm_cfg, FakeChatModel)
|
|
|
|
assert model.kwargs["generate_kwargs"] == {"max_tokens": 321}
|
|
assert "reasoning_effort" not in model.kwargs
|
|
|
|
|
|
def test_host_ai_gpt_oss_uses_low_reasoning_effort(monkeypatch):
|
|
from agentscope.model import OpenAIChatModel
|
|
|
|
from adclaw.agents.model_factory import _create_remote_model_instance
|
|
|
|
class FakeOpenAIChatModel(OpenAIChatModel):
|
|
def __init__(self, model_name, **kwargs):
|
|
self.model_name = model_name
|
|
self.kwargs = kwargs
|
|
|
|
monkeypatch.delenv("MASKANX_HOST_AI_REASONING_EFFORT", raising=False)
|
|
llm_cfg = SimpleNamespace(
|
|
provider_id="maskanx-host-ai",
|
|
model="@cf/openai/gpt-oss-20b",
|
|
api_key="ach_test",
|
|
base_url="https://real.maskanx.app/api/host-ai/v1",
|
|
)
|
|
|
|
model = _create_remote_model_instance(llm_cfg, FakeOpenAIChatModel)
|
|
|
|
assert model.kwargs["reasoning_effort"] == "low"
|
|
|
|
|
|
def test_host_ai_gpt_oss_reasoning_effort_can_be_disabled(monkeypatch):
|
|
from agentscope.model import OpenAIChatModel
|
|
|
|
from adclaw.agents.model_factory import _create_remote_model_instance
|
|
|
|
class FakeOpenAIChatModel(OpenAIChatModel):
|
|
def __init__(self, model_name, **kwargs):
|
|
self.model_name = model_name
|
|
self.kwargs = kwargs
|
|
|
|
monkeypatch.setenv("MASKANX_HOST_AI_REASONING_EFFORT", "off")
|
|
llm_cfg = SimpleNamespace(
|
|
provider_id="maskanx-host-ai",
|
|
model="@cf/openai/gpt-oss-20b",
|
|
api_key="ach_test",
|
|
base_url="https://real.maskanx.app/api/host-ai/v1",
|
|
)
|
|
|
|
model = _create_remote_model_instance(llm_cfg, FakeOpenAIChatModel)
|
|
|
|
assert "reasoning_effort" not in model.kwargs
|
|
|
|
|
|
def test_non_host_ai_remote_model_does_not_get_host_generation_cap():
|
|
from adclaw.agents.model_factory import _create_remote_model_instance
|
|
|
|
class FakeChatModel:
|
|
def __init__(self, model_name, **kwargs):
|
|
self.model_name = model_name
|
|
self.kwargs = kwargs
|
|
|
|
llm_cfg = SimpleNamespace(
|
|
provider_id="openai",
|
|
model="gpt-4.1",
|
|
api_key="sk-test",
|
|
base_url="https://api.openai.com/v1",
|
|
)
|
|
|
|
model = _create_remote_model_instance(llm_cfg, FakeChatModel)
|
|
|
|
assert "generate_kwargs" not in model.kwargs
|
|
assert "reasoning_effort" not in model.kwargs
|
|
|
|
|
|
def test_tool_result_string_is_truncated_for_host_ai_context(monkeypatch):
|
|
from adclaw.agents.model_factory import _create_file_block_support_formatter
|
|
|
|
class FakeFormatter:
|
|
@staticmethod
|
|
def convert_tool_result_to_string(output):
|
|
return str(output), []
|
|
|
|
monkeypatch.setenv("MASKANX_HOST_AI_ENABLED", "true")
|
|
monkeypatch.setenv("MASKANX_LLM_TOOL_RESULT_MAX_CHARS", "160")
|
|
formatter = _create_file_block_support_formatter(FakeFormatter)
|
|
raw = "A" * 200 + "TAIL"
|
|
|
|
text, data = formatter.convert_tool_result_to_string(raw)
|
|
|
|
assert data == []
|
|
assert len(text) <= 160
|
|
assert text.startswith("A")
|
|
assert text.endswith("TAIL")
|
|
assert "tool result truncated from 204 to 160 chars" in text
|
|
|
|
|
|
def test_parent_tool_result_conversion_is_truncated(monkeypatch):
|
|
from adclaw.agents.model_factory import _create_file_block_support_formatter
|
|
|
|
class FakeFormatter:
|
|
@staticmethod
|
|
def convert_tool_result_to_string(output):
|
|
return "B" * 220 + "END", [("file.txt", {"type": "file"})]
|
|
|
|
monkeypatch.setenv("MASKANX_HOST_AI_ENABLED", "true")
|
|
monkeypatch.setenv("MASKANX_LLM_TOOL_RESULT_MAX_CHARS", "180")
|
|
formatter = _create_file_block_support_formatter(FakeFormatter)
|
|
|
|
text, data = formatter.convert_tool_result_to_string([{"type": "text"}])
|
|
|
|
assert data == [("file.txt", {"type": "file"})]
|
|
assert len(text) <= 180
|
|
assert text.endswith("END")
|
|
assert "tool result truncated from 223 to 180 chars" in text
|
|
|
|
|
|
def test_tool_result_string_is_not_truncated_for_byo_provider(monkeypatch):
|
|
from adclaw.agents.model_factory import _create_file_block_support_formatter
|
|
|
|
class FakeFormatter:
|
|
@staticmethod
|
|
def convert_tool_result_to_string(output):
|
|
return str(output), []
|
|
|
|
monkeypatch.delenv("MASKANX_HOST_AI_ENABLED", raising=False)
|
|
monkeypatch.delenv("MASKANX_HOST_AI_BASE_URL", raising=False)
|
|
monkeypatch.setenv("MASKANX_LLM_TOOL_RESULT_MAX_CHARS", "160")
|
|
formatter = _create_file_block_support_formatter(FakeFormatter)
|
|
raw = "A" * 200 + "TAIL"
|
|
|
|
text, data = formatter.convert_tool_result_to_string(raw)
|
|
|
|
assert data == []
|
|
assert text == raw
|
|
|
|
|
|
def test_file_block_fallback_tool_result_is_truncated(monkeypatch):
|
|
from adclaw.agents.model_factory import _create_file_block_support_formatter
|
|
|
|
class FakeFormatter:
|
|
@staticmethod
|
|
def convert_tool_result_to_string(output):
|
|
raise ValueError("Unsupported block type: file")
|
|
|
|
monkeypatch.setenv("MASKANX_HOST_AI_ENABLED", "true")
|
|
monkeypatch.setenv("MASKANX_LLM_TOOL_RESULT_MAX_CHARS", "220")
|
|
formatter = _create_file_block_support_formatter(FakeFormatter)
|
|
long_name = "deck-" + ("X" * 260) + ".pptx"
|
|
block = {"type": "file", "name": long_name, "path": "/tmp/deck.pptx"}
|
|
|
|
text, data = formatter.convert_tool_result_to_string([block])
|
|
|
|
assert data == [("/tmp/deck.pptx", block)]
|
|
assert len(text) <= 220
|
|
assert "tool result truncated" in text
|
|
assert "/tmp/deck.pptx" in text
|