import json
from types import SimpleNamespace
import pytest
from landppt.ai.base import AIResponse, ImageContent, MessageRole, TextContent
from landppt.services.slide.edit_agent import prompt as agent_prompt
from landppt.services.slide.slide_edit_agent_service import (
DraftRefError,
SlideDraft,
SlideEditAgentContext,
SlideEditAgentRequest,
SlideEditAgentService,
SlideEditToolbox,
ToolProtocol,
ToolProtocolRegistry,
agent_run_registry,
coerce_agent_max_iterations,
compute_slide_html_hash,
sanitize_slide_html,
strip_agent_ids,
tool_protocol_registry,
validate_slide_html,
)
BASE_HTML = (
'
'
'
Long Original Title
'
'
'
"
"
)
# ---------------------------------------------------------------------------
# HTML 安全
# ---------------------------------------------------------------------------
def test_compute_slide_html_hash_is_stable_for_equivalent_text():
assert compute_slide_html_hash(" A
\n") == compute_slide_html_hash("A
")
assert compute_slide_html_hash("A
") != compute_slide_html_hash("B
")
def test_sanitize_slide_html_removes_scripts_event_handlers_and_agent_ids():
html = (
'"
)
sanitized = sanitize_slide_html(html)
assert "Hi
')
assert result.valid is False
assert "script tags are not allowed" in result.errors
assert "inline event handlers are not allowed" in result.errors
assert ""})
assert result.ok is False
assert draft.html == BASE_HTML
def test_undo_last_edit_reverts_the_previous_tool_call():
toolbox, draft = _toolbox()
toolbox.execute("set_text", {"selector": "h1", "text": "Short"})
assert "Short" in draft.html
result = toolbox.execute("undo_last_edit", {})
assert result.ok is True
assert draft.html == BASE_HTML
def test_unsupported_tool_reports_available_tools():
toolbox, _ = _toolbox()
result = toolbox.execute("teleport", {})
assert result.ok is False
assert "read_slide" in result.data["available_tools"]
def test_transcript_records_every_call_with_outcome():
toolbox, _ = _toolbox()
toolbox.execute("read_slide", {})
toolbox.execute("set_text", {"selector": "ul", "text": "oops"})
assert [entry["tool"] for entry in toolbox.transcript] == ["read_slide", "set_text"]
assert [entry["ok"] for entry in toolbox.transcript] == [True, False]
# ---------------------------------------------------------------------------
# 协议选择
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"message,expected",
[
("400 invalid_request_error: Unsupported parameter: 'tools'", True),
("Unknown parameter: tool_choice", True),
("this model does not support function calling", True),
("429 rate limit exceeded for tools tier", False),
("connection reset by peer", False),
("tool execution failed", False),
],
)
def test_tool_parameter_rejection_detection_is_narrow(message, expected):
from landppt.services.slide.edit_agent import is_tool_parameter_rejection
assert is_tool_parameter_rejection(RuntimeError(message)) is expected
def test_protocol_registry_defaults_to_native_and_caches_downgrades():
registry = ToolProtocolRegistry()
key = ToolProtocolRegistry.key_for("proxy", "mystery")
assert registry.preferred(key) is ToolProtocol.NATIVE
registry.mark_text_only(key, "ignored tool schemas")
assert registry.preferred(key) is ToolProtocol.TEXT
assert registry.downgrade_reason(key) == "ignored tool schemas"
assert registry.preferred(ToolProtocolRegistry.key_for("openai", "gpt")) is ToolProtocol.NATIVE
# ---------------------------------------------------------------------------
# 提示词
# ---------------------------------------------------------------------------
def test_prompt_inlines_small_slide_html():
context = SlideEditAgentContext.from_request(_request())
payload = agent_prompt.build_initial_context(
context, SlideDraft(context.base_html), max_iterations=12
)
assert payload["slide_html"] == BASE_HTML
assert "slide_structure" not in payload
def test_prompt_swaps_huge_html_for_a_structure_outline():
big = '' + "".join(f"
Line {i} " + "x" * 80 + "
" for i in range(200)) + "
"
context = SlideEditAgentContext.from_request(_request(slideContent=big))
payload = agent_prompt.build_initial_context(
context, SlideDraft(context.base_html), max_iterations=12
)
assert "slide_html" not in payload
assert payload["slide_structure"]
assert "read_slide" in payload["slide_html_note"]
def test_prompt_sanitizes_and_limits_conversation_history():
history = [{"role": "system", "content": "ignored"}]
history += [{"role": "user", "content": f"msg {i}"} for i in range(20)]
context = SlideEditAgentContext.from_request(_request(chatHistory=history))
cleaned = agent_prompt.conversation_history_context(context.request)
assert len(cleaned) == agent_prompt.MAX_CONVERSATION_HISTORY_MESSAGES
assert all(item["role"] in {"user", "assistant"} for item in cleaned)
assert cleaned[-1]["content"] == "msg 19"
def test_prompt_truncates_overlong_history_messages():
long_message = "x" * 5000
context = SlideEditAgentContext.from_request(
_request(chatHistory=[{"role": "user", "content": long_message}])
)
cleaned = agent_prompt.conversation_history_context(context.request)
assert len(cleaned[0]["content"]) == agent_prompt.MAX_CONVERSATION_HISTORY_MESSAGE_CHARS
assert cleaned[0]["content"].endswith("...")
def test_prompt_omits_data_urls_from_the_text_payload():
context = SlideEditAgentContext.from_request(
_request(slideScreenshot="data:image/png;base64,AAAA", visionEnabled=True)
)
payload = agent_prompt.build_initial_context(
context, SlideDraft(context.base_html), max_iterations=12
)
assert payload["vision"]["attachments"][0]["url"] == "[attached data URL omitted from text prompt]"
assert payload["vision"]["attached_image_count"] == 1
# ---------------------------------------------------------------------------
# 循环
# ---------------------------------------------------------------------------
def _response(content="", tool_calls=None):
return AIResponse(content=content, model="fake", usage={}, tool_calls=tool_calls or [])
def _native_call(call_id, name, arguments):
return {"id": call_id, "function": {"name": name, "arguments": json.dumps(arguments)}}
def _text_action(name, arguments, thought="because"):
return _response(
json.dumps({"thought": thought, "action": name, "action_input": arguments})
)
class _ScriptedPPTService:
def __init__(self, script, provider="openai", model="m"):
self.script = list(script)
self.calls = []
self._provider = provider
self._model = model
async def get_role_provider_async(self, role):
return None, {"provider": self._provider, "model": self._model}
async def _chat_completion_for_role(self, role, **kwargs):
# 适配器会持续复用同一个 messages 列表,这里必须快照,否则断言看到的是终态。
self.calls.append({"role": role, **kwargs, "messages": list(kwargs["messages"])})
item = self.script.pop(0)
if isinstance(item, Exception):
raise item
return item
async def _run(service, request, handle=None, cancel_on=None):
events = []
async def emit(event):
events.append(event)
if cancel_on and handle and event["type"] == cancel_on:
handle.cancel("test")
result = await SlideEditAgentService().run_agent(request, service, emit, handle=handle)
return result, events
@pytest.fixture(autouse=True)
def _reset_protocol_registry():
tool_protocol_registry.reset()
yield
tool_protocol_registry.reset()
@pytest.mark.asyncio
async def test_agent_runs_native_tool_calls_and_returns_a_proposal():
service = _ScriptedPPTService(
[
_response("looking", [_native_call("c1", "find_elements", {"selector": "h1"})]),
_response("", [_native_call("c2", "set_text", {"selector": "h1", "text": "Short"})]),
_response("Shortened the title."),
]
)
result, events = await _run(service, _request())
assert result.status == "completed"
assert result.summary == "Shortened the title."
assert "Short
" in result.proposal.html_content
assert result.proposal.changed is True
assert result.proposal.validation.valid is True
assert service.calls[0]["tool_choice"] == "auto"
assert len(service.calls[0]["tools"]) == len(SlideEditToolbox.tool_names())
assert [event["type"] for event in events][:4] == [
"run_started",
"turn_started",
"thinking",
"tool_started",
]
assert [event["seq"] for event in events] == list(range(1, len(events) + 1))
assert all(event["runId"] == result.run_id for event in events)
@pytest.mark.asyncio
async def test_agent_emits_draft_updated_only_for_successful_mutations():
service = _ScriptedPPTService(
[
_response("", [_native_call("c1", "read_slide", {})]),
_response("", [_native_call("c2", "set_text", {"selector": "ul", "text": "no"})]),
_response("", [_native_call("c3", "set_text", {"selector": "h1", "text": "Short"})]),
_response("done"),
]
)
_, events = await _run(service, _request())
drafts = [event for event in events if event["type"] == "draft_updated"]
assert len(drafts) == 1
assert drafts[0]["revision"] == 1
assert drafts[0]["changed"] is True
assert "Short" in drafts[0]["html"]
@pytest.mark.asyncio
async def test_agent_downgrades_when_provider_ignores_native_tool_schemas():
service = _ScriptedPPTService(
[
_text_action("find_elements", {"selector": "h1"}),
_text_action("set_text", {"selector": "h1", "text": "Short"}),
_text_action("final", {"summary": "done via text protocol"}),
],
provider="proxy",
model="mystery",
)
result, events = await _run(service, _request())
types = [event["type"] for event in events]
assert "protocol_changed" in types
assert result.status == "completed"
assert result.summary == "done via text protocol"
assert "Short" in result.proposal.html_content
# 后续请求直接从文本协议起步,不再浪费一轮。
key = ToolProtocolRegistry.key_for("proxy", "mystery")
assert tool_protocol_registry.preferred(key) is ToolProtocol.TEXT
@pytest.mark.asyncio
async def test_agent_downgrades_when_provider_rejects_the_tools_parameter():
service = _ScriptedPPTService(
[
RuntimeError("400 invalid_request_error: Unsupported parameter: 'tools'"),
_text_action("set_text", {"selector": "h1", "text": "Short"}),
_text_action("final", {"summary": "fallback worked"}),
],
provider="weird",
)
result, events = await _run(service, _request())
assert result.status == "completed"
assert "Short" in result.proposal.html_content
assert any(event["type"] == "protocol_changed" for event in events)
assert "tools" not in service.calls[-1]
@pytest.mark.asyncio
async def test_agent_does_not_downgrade_on_unrelated_model_errors():
service = _ScriptedPPTService([RuntimeError("429 rate limit exceeded for tools tier")])
events = []
async def emit(event):
events.append(event)
with pytest.raises(RuntimeError, match="rate limit"):
await SlideEditAgentService().run_agent(_request(), service, emit)
assert not any(event["type"] == "protocol_changed" for event in events)
error_events = [event for event in events if event["type"] == "error"]
assert error_events and error_events[0]["phase"] == "model"
@pytest.mark.asyncio
async def test_agent_retries_once_when_text_protocol_reply_is_unstructured():
tool_protocol_registry.mark_text_only(
ToolProtocolRegistry.key_for("proxy", "m"), "test"
)
service = _ScriptedPPTService(
[
_response("I will just chat instead of returning JSON."),
_text_action("set_text", {"selector": "h1", "text": "Short"}),
_text_action("final", {"summary": "recovered"}),
],
provider="proxy",
)
result, _ = await _run(service, _request())
assert result.summary == "recovered"
assert "Short" in result.proposal.html_content
@pytest.mark.asyncio
async def test_agent_stops_at_the_next_checkpoint_when_cancelled():
service = _ScriptedPPTService(
[
_response("", [_native_call("c1", "set_text", {"selector": "h1", "text": "Half"})]),
_response("should never run"),
]
)
handle = agent_run_registry.register("run-cancel-test", 1)
result, events = await _run(service, _request(), handle=handle, cancel_on="draft_updated")
agent_run_registry.release("run-cancel-test")
assert result.status == "cancelled"
assert len(service.calls) == 1
# 停止前的改动仍然作为草稿返回,用户可以自行决定保留还是撤销。
assert "Half" in result.proposal.html_content
assert events[-1]["type"] == "run_finished"
assert events[-1]["status"] == "cancelled"
@pytest.mark.asyncio
async def test_agent_finishes_with_max_iterations_status():
service = _ScriptedPPTService(
[_response("", [_native_call(f"c{i}", "read_slide", {})]) for i in range(5)]
)
result, _ = await _run(service, _request(maxIterations=3))
assert result.status == "max_iterations"
assert result.iterations_used == 3
assert len(service.calls) == 3
assert result.proposal.changed is False
@pytest.mark.asyncio
async def test_agent_reports_unsupported_tool_and_keeps_going():
service = _ScriptedPPTService(
[
_response("", [_native_call("c1", "teleport", {})]),
_response("recovered"),
]
)
result, events = await _run(service, _request())
failed = [
event for event in events if event["type"] == "tool_finished" and event["ok"] is False
]
assert failed and "unsupported tool" in failed[0]["summary"]
assert result.status == "completed"
@pytest.mark.asyncio
async def test_agent_emits_error_event_when_a_tool_raises(monkeypatch):
def boom(self, tool_name, tool_input):
raise RuntimeError("tool exploded")
monkeypatch.setattr(SlideEditToolbox, "execute", boom)
service = _ScriptedPPTService(
[_response("", [_native_call("c1", "read_slide", {})])]
)
events = []
async def emit(event):
events.append(event)
with pytest.raises(RuntimeError, match="tool exploded"):
await SlideEditAgentService().run_agent(_request(), service, emit)
error_events = [event for event in events if event["type"] == "error"]
assert error_events == [
{
"type": "error",
"runId": error_events[0]["runId"],
"seq": error_events[0]["seq"],
"phase": "tool",
"message": "tool exploded",
"errorType": "RuntimeError",
"iteration": 1,
"tool": "read_slide",
}
]
@pytest.mark.asyncio
async def test_agent_sends_multimodal_content_in_vision_mode():
service = _ScriptedPPTService([_response("looked at it")])
await _run(
service,
_request(
visionEnabled=True,
slideScreenshot="data:image/png;base64,AAAA",
images=[{"url": "https://example.com/ref.png", "name": "ref"}],
),
)
assert service.calls[0]["role"] == "vision_analysis"
user_message = service.calls[0]["messages"][1]
assert user_message.role is MessageRole.USER
assert isinstance(user_message.content[0], TextContent)
image_urls = [
part.image_url["url"] for part in user_message.content if isinstance(part, ImageContent)
]
assert image_urls == ["data:image/png;base64,AAAA", "https://example.com/ref.png"]
@pytest.mark.asyncio
async def test_agent_uses_editor_role_without_vision_inputs():
service = _ScriptedPPTService([_response("done")])
await _run(service, _request(visionEnabled=True))
assert service.calls[0]["role"] == "editor"
@pytest.mark.asyncio
async def test_tool_results_are_fed_back_as_tool_role_messages():
service = _ScriptedPPTService(
[
_response("", [_native_call("c1", "read_slide", {})]),
_response("done"),
]
)
await _run(service, _request())
roles = [message.role for message in service.calls[-1]["messages"]]
assert roles == [
MessageRole.SYSTEM,
MessageRole.USER,
MessageRole.ASSISTANT,
MessageRole.TOOL,
]
tool_message = service.calls[-1]["messages"][-1]
assert tool_message.tool_call_id == "c1"
assert json.loads(tool_message.content)["tool"] == "read_slide"