mirror of
https://github.com/sligter/LandPPT.git
synced 2026-09-01 15:54:37 +08:00
919 lines
30 KiB
Python
919 lines
30 KiB
Python
import json
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from landppt.ai.base import ImageContent, TextContent
|
|
from landppt.services.slide.slide_edit_agent_service import (
|
|
SlideEditAgentContext,
|
|
SlideEditAgentRequest,
|
|
SlideEditAgentService,
|
|
SlideEditToolRunner,
|
|
compute_slide_html_hash,
|
|
coerce_agent_max_iterations,
|
|
parse_agent_action,
|
|
sanitize_slide_html,
|
|
strip_agent_ids,
|
|
validate_slide_html,
|
|
)
|
|
|
|
|
|
def test_parse_agent_action_supports_json_code_fence():
|
|
action = parse_agent_action(
|
|
"```json\n"
|
|
+ json.dumps(
|
|
{
|
|
"thought": "Inspect the slide before editing.",
|
|
"action": "inspect_slide_html",
|
|
"action_input": {"slide_index": 1},
|
|
}
|
|
)
|
|
+ "\n```"
|
|
)
|
|
|
|
assert action.thought == "Inspect the slide before editing."
|
|
assert action.action == "inspect_slide_html"
|
|
assert action.action_input == {"slide_index": 1}
|
|
|
|
|
|
def test_parse_agent_action_falls_back_to_final_for_unparseable_text():
|
|
action = parse_agent_action("I cannot parse this as JSON.")
|
|
|
|
assert action.action == "final"
|
|
assert action.action_input["summary"] == "I cannot parse this as JSON."
|
|
|
|
|
|
def test_coerce_agent_max_iterations_defaults_and_clamps():
|
|
assert coerce_agent_max_iterations(None) == 6
|
|
assert coerce_agent_max_iterations(1) == 2
|
|
assert coerce_agent_max_iterations(8) == 8
|
|
assert coerce_agent_max_iterations(99) == 99
|
|
assert coerce_agent_max_iterations(999) == 100
|
|
assert coerce_agent_max_iterations("bad") == 6
|
|
|
|
|
|
def test_compute_slide_html_hash_is_stable_for_equivalent_text():
|
|
assert compute_slide_html_hash(" <div>A</div>\n") == compute_slide_html_hash("<div>A</div>")
|
|
assert compute_slide_html_hash("<div>A</div>") != compute_slide_html_hash("<div>B</div>")
|
|
|
|
|
|
def test_sanitize_slide_html_removes_scripts_event_handlers_and_agent_ids():
|
|
html = (
|
|
'<div data-agent-id="a1" onclick="bad()" style="width:1280px;height:720px">'
|
|
'<a href="javascript:bad()">x</a><script>alert(1)</script>'
|
|
"</div>"
|
|
)
|
|
|
|
sanitized = sanitize_slide_html(html)
|
|
|
|
assert "<script" not in sanitized.lower()
|
|
assert "onclick" not in sanitized.lower()
|
|
assert "javascript:" not in sanitized.lower()
|
|
assert "data-agent-id" not in strip_agent_ids(sanitized)
|
|
|
|
|
|
def test_validate_slide_html_reports_unsafe_original_html():
|
|
result = validate_slide_html('<div><script>alert(1)</script><p onclick="x()">Hi</p></div>')
|
|
|
|
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 "<script" not in result.sanitized_html.lower()
|
|
|
|
|
|
def test_validate_slide_html_rejects_encoded_javascript_urls():
|
|
result = validate_slide_html('<div><a href="javascript:alert(1)">x</a></div>')
|
|
|
|
assert result.valid is False
|
|
assert "javascript urls are not allowed" in result.errors
|
|
assert "javascript:" not in result.sanitized_html.lower()
|
|
assert "href=" not in result.sanitized_html.lower()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"html",
|
|
[
|
|
'<div><a href="java script:alert(1)">x</a></div>',
|
|
'<div><a href="jav	ascript:alert(1)">x</a></div>',
|
|
],
|
|
)
|
|
def test_validate_slide_html_rejects_encoded_control_javascript_urls(html):
|
|
result = validate_slide_html(html)
|
|
|
|
assert result.valid is False
|
|
assert "javascript urls are not allowed" in result.errors
|
|
assert "href=" not in result.sanitized_html.lower()
|
|
|
|
|
|
def test_validate_slide_html_rejects_srcdoc_attributes():
|
|
result = validate_slide_html(
|
|
'<div><iframe srcdoc="<script>alert(1)</script>"></iframe></div>'
|
|
)
|
|
|
|
assert result.valid is False
|
|
assert "srcdoc attributes are not allowed" in result.errors
|
|
assert "srcdoc" not in result.sanitized_html.lower()
|
|
assert "<script" not in result.sanitized_html.lower()
|
|
|
|
|
|
def test_validate_slide_html_accepts_clean_slide_html():
|
|
result = validate_slide_html('<div style="width:1280px;height:720px"><h1>Hello</h1></div>')
|
|
|
|
assert result.valid is True
|
|
assert result.errors == []
|
|
assert "Hello" in result.sanitized_html
|
|
|
|
|
|
def _tool_request(**overrides):
|
|
data = {
|
|
"projectId": "p1",
|
|
"slideIndex": 1,
|
|
"userRequest": "Make the title shorter",
|
|
"slideTitle": "Original",
|
|
"slideContent": '<div style="width:1280px;height:720px"><h1>Long Original Title</h1><p>Body</p></div>',
|
|
"projectInfo": {"title": "Project", "topic": "Topic", "scenario": "Pitch"},
|
|
"slideOutline": {"title": "Original", "content_points": ["Body"]},
|
|
}
|
|
data.update(overrides)
|
|
return SlideEditAgentRequest(**data)
|
|
|
|
|
|
def _tool_context(**overrides):
|
|
request = _tool_request(**overrides)
|
|
return SlideEditAgentContext.from_request(request)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_inspects_slide_html():
|
|
runner = SlideEditToolRunner(_tool_context())
|
|
|
|
result = await runner.execute_tool("inspect_slide_html", {"slide_index": 1})
|
|
|
|
assert result["success"] is True
|
|
assert result["tool"] == "inspect_slide_html"
|
|
assert result["headings"][0]["text"] == "Long Original Title"
|
|
assert result["text_blocks"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_compact_observation_keeps_tool_results_needed_by_model():
|
|
service = SlideEditAgentService()
|
|
runner = SlideEditToolRunner(_tool_context())
|
|
|
|
inspect_result = await runner.execute_tool("inspect_slide_html", {})
|
|
inspect_compact = service._compact_observation(inspect_result)
|
|
|
|
assert inspect_compact["headings"][0]["text"] == "Long Original Title"
|
|
|
|
select_result = await runner.execute_tool("select_elements", {"selector": "h1"})
|
|
select_compact = service._compact_observation(select_result)
|
|
|
|
assert select_compact["matches"][0]["agent_id"].startswith("agent-el-")
|
|
|
|
slide_result = await runner.execute_tool("get_slide", {})
|
|
slide_compact = service._compact_observation(slide_result)
|
|
|
|
assert "html_content" in slide_compact["slide"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_replace_slide_html_updates_draft_not_base():
|
|
context = _tool_context()
|
|
runner = SlideEditToolRunner(context)
|
|
original_hash = context.base_hash
|
|
|
|
result = await runner.execute_tool(
|
|
"replace_slide_html",
|
|
{"html": '<div style="width:1280px;height:720px"><h1>New</h1></div>'},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert "New" in runner.current_html
|
|
assert context.base_hash == original_hash
|
|
assert "Long Original Title" in context.base_html
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_replace_slide_html_rejects_invalid_without_mutating_draft():
|
|
runner = SlideEditToolRunner(_tool_context())
|
|
original_html = runner.current_html
|
|
|
|
result = await runner.execute_tool(
|
|
"replace_slide_html",
|
|
{"html": '<div style="width:1280px;height:720px"><script>alert(1)</script><h1>Bad</h1></div>'},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["tool"] == "replace_slide_html"
|
|
assert "script tags are not allowed" in result["errors"]
|
|
assert runner.current_html == original_html
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_updates_text_by_selector():
|
|
runner = SlideEditToolRunner(_tool_context())
|
|
|
|
result = await runner.execute_tool("update_text", {"selector": "h1", "text": "Short Title"})
|
|
|
|
assert result["success"] is True
|
|
assert "Short Title" in runner.current_html
|
|
assert "Long Original Title" not in runner.current_html
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_selector_takes_priority_over_selected_element_context():
|
|
runner = SlideEditToolRunner(
|
|
_tool_context(
|
|
mode="element",
|
|
slideContent=(
|
|
'<div style="width:1280px;height:720px">'
|
|
'<h1 data-quick-ai-id="el1">Long Original Title</h1><p>Body</p>'
|
|
"</div>"
|
|
),
|
|
selectedElementId="el1",
|
|
)
|
|
)
|
|
|
|
result = await runner.execute_tool("update_text", {"selector": "p", "text": "Updated Body"})
|
|
|
|
assert result["success"] is True
|
|
assert "Long Original Title" in runner.current_html
|
|
assert "Updated Body" in runner.current_html
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_update_style_uses_css_whitelist():
|
|
runner = SlideEditToolRunner(_tool_context())
|
|
|
|
result = await runner.execute_tool(
|
|
"update_style",
|
|
{
|
|
"selector": "h1",
|
|
"styles": {
|
|
"color": "#123456",
|
|
"font-size": "48px",
|
|
"position": "fixed",
|
|
"behavior": "url(bad)",
|
|
},
|
|
},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert "color: #123456" in runner.current_html
|
|
assert "font-size: 48px" in runner.current_html
|
|
assert "position: fixed" not in runner.current_html
|
|
assert "behavior" not in runner.current_html
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_replaces_element_and_preserves_quick_ai_id_in_draft():
|
|
runner = SlideEditToolRunner(
|
|
_tool_context(
|
|
mode="element",
|
|
slideContent=(
|
|
'<div style="width:1280px;height:720px">'
|
|
'<h1 data-quick-ai-id="el1">Long Original Title</h1><p>Body</p>'
|
|
"</div>"
|
|
),
|
|
selectedElementHtml='<h1 data-quick-ai-id="el1">Long Original Title</h1>',
|
|
selectedElementId="el1",
|
|
)
|
|
)
|
|
|
|
result = await runner.execute_tool(
|
|
"replace_element_html",
|
|
{"element_id": "el1", "html": '<h1 data-quick-ai-id="el1">Short Title</h1>'},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert 'data-quick-ai-id="el1"' in runner.current_html
|
|
assert "Short Title" in runner.current_html
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_replace_element_by_selector_preserves_target_id_only():
|
|
runner = SlideEditToolRunner(
|
|
_tool_context(
|
|
mode="element",
|
|
slideContent=(
|
|
'<div style="width:1280px;height:720px">'
|
|
'<h1 data-quick-ai-id="el1">Long Original Title</h1>'
|
|
'<p data-quick-ai-id="body1">Body</p>'
|
|
"</div>"
|
|
),
|
|
selectedElementId="el1",
|
|
)
|
|
)
|
|
|
|
result = await runner.execute_tool(
|
|
"replace_element_html",
|
|
{"selector": "p", "html": "<p>Updated Body</p>"},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert runner.current_html.count('data-quick-ai-id="el1"') == 1
|
|
assert 'data-quick-ai-id="body1"' in runner.current_html
|
|
assert "Updated Body" in runner.current_html
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_replace_element_missing_id_fails_without_mutating_draft():
|
|
runner = SlideEditToolRunner(
|
|
_tool_context(
|
|
mode="element",
|
|
selectedElementId="missing",
|
|
)
|
|
)
|
|
original_html = runner.current_html
|
|
|
|
result = await runner.execute_tool(
|
|
"replace_element_html",
|
|
{"element_id": "missing", "html": "<h1>Short Title</h1>"},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["tool"] == "replace_element_html"
|
|
assert "not found" in result["error"]
|
|
assert runner.current_html == original_html
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_update_text_missing_target_fails_without_mutating_draft():
|
|
runner = SlideEditToolRunner(_tool_context())
|
|
original_html = runner.current_html
|
|
|
|
result = await runner.execute_tool("update_text", {"text": "Short Title"})
|
|
|
|
assert result["success"] is False
|
|
assert result["tool"] == "update_text"
|
|
assert "requires" in result["error"]
|
|
assert runner.current_html == original_html
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_delete_element_missing_target_fails_without_mutating_draft():
|
|
runner = SlideEditToolRunner(_tool_context())
|
|
original_html = runner.current_html
|
|
|
|
result = await runner.execute_tool("delete_element", {})
|
|
|
|
assert result["success"] is False
|
|
assert result["tool"] == "delete_element"
|
|
assert "requires" in result["error"]
|
|
assert runner.current_html == original_html
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_replace_element_rejects_unsafe_fragment_without_mutating_draft():
|
|
runner = SlideEditToolRunner(
|
|
_tool_context(
|
|
mode="element",
|
|
slideContent=(
|
|
'<div style="width:1280px;height:720px">'
|
|
'<h1 data-quick-ai-id="el1">Long Original Title</h1><p>Body</p>'
|
|
"</div>"
|
|
),
|
|
selectedElementId="el1",
|
|
)
|
|
)
|
|
original_html = runner.current_html
|
|
|
|
result = await runner.execute_tool(
|
|
"replace_element_html",
|
|
{"element_id": "el1", "html": '<h1 onclick="bad()">Bad</h1>'},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert "inline event handlers are not allowed" in result["errors"]
|
|
assert runner.current_html == original_html
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_insert_element_rejects_unsafe_fragment_without_mutating_draft():
|
|
runner = SlideEditToolRunner(_tool_context())
|
|
original_html = runner.current_html
|
|
|
|
result = await runner.execute_tool(
|
|
"insert_element",
|
|
{"parent_selector": "div", "html": "<h2><script>alert(1)</script>Bad</h2>"},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert "script tags are not allowed" in result["errors"]
|
|
assert runner.current_html == original_html
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_insert_element_missing_target_fails_without_mutating_full_html_body():
|
|
html = (
|
|
"<!doctype html><html><head><title>Slide</title></head>"
|
|
'<body><section id="slide"><p>Body</p></section></body></html>'
|
|
)
|
|
runner = SlideEditToolRunner(_tool_context(slideContent=html))
|
|
original_html = runner.current_html
|
|
|
|
result = await runner.execute_tool("insert_element", {"html": "<p>New</p>"})
|
|
|
|
assert result["success"] is False
|
|
assert result["tool"] == "insert_element"
|
|
assert "requires parent_selector or element_id" in result["error"]
|
|
assert runner.current_html == original_html
|
|
assert "New" not in runner.current_html
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_insert_element_succeeds_with_explicit_parent_selector():
|
|
runner = SlideEditToolRunner(_tool_context())
|
|
|
|
result = await runner.execute_tool(
|
|
"insert_element",
|
|
{"parent_selector": "div", "html": "<h2>New Section</h2>"},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["tool"] == "insert_element"
|
|
assert "<h2>New Section</h2>" in runner.current_html
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_runner_invalid_selector_returns_structured_error():
|
|
runner = SlideEditToolRunner(_tool_context())
|
|
original_html = runner.current_html
|
|
|
|
result = await runner.execute_tool(
|
|
"update_text",
|
|
{"selector": "[", "text": "Short Title"},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["tool"] == "update_text"
|
|
assert "invalid selector" in result["error"]
|
|
assert runner.current_html == original_html
|
|
|
|
|
|
def test_tool_runner_build_proposal_strips_agent_ids():
|
|
runner = SlideEditToolRunner(
|
|
_tool_context(
|
|
slideContent='<div style="width:1280px;height:720px"><h1 data-agent-id="a1">Title</h1></div>'
|
|
)
|
|
)
|
|
|
|
proposal = runner.build_proposal("Edited title")
|
|
|
|
assert proposal.proposal_id
|
|
assert proposal.summary == "Edited title"
|
|
assert "data-agent-id" not in proposal.html_content
|
|
assert proposal.validation.valid is True
|
|
|
|
|
|
def _agent_prompt_context(service: SlideEditAgentService, request: SlideEditAgentRequest):
|
|
runner = SlideEditToolRunner(SlideEditAgentContext.from_request(request))
|
|
prompt = service._build_prompt(request, runner, scratchpad=[], max_iterations=6)
|
|
return json.loads(prompt.split("\n\n", 1)[1])
|
|
|
|
|
|
def test_slide_edit_agent_prompt_includes_sanitized_conversation_history():
|
|
service = SlideEditAgentService()
|
|
request = _tool_request(
|
|
chatHistory=[
|
|
{"role": "system", "content": "Do not include this."},
|
|
{"role": "user", "content": "Make the heading shorter first."},
|
|
{"role": "assistant", "content": "I shortened the heading."},
|
|
{"role": "tool", "content": "Internal tool output."},
|
|
{"role": "assistant", "content": " "},
|
|
]
|
|
)
|
|
|
|
context = _agent_prompt_context(service, request)
|
|
|
|
assert context["conversation_history"] == [
|
|
{"role": "user", "content": "Make the heading shorter first."},
|
|
{"role": "assistant", "content": "I shortened the heading."},
|
|
]
|
|
assert context["user_request"] == "Make the title shorter"
|
|
|
|
|
|
def test_slide_edit_agent_prompt_limits_conversation_history_to_recent_messages():
|
|
service = SlideEditAgentService()
|
|
history = [
|
|
{"role": "user", "content": f"message {index}"}
|
|
for index in range(14)
|
|
]
|
|
history.append({"role": "assistant", "content": "x" * 1500})
|
|
request = _tool_request(chatHistory=history)
|
|
|
|
context = _agent_prompt_context(service, request)
|
|
conversation_history = context["conversation_history"]
|
|
|
|
assert len(conversation_history) == 10
|
|
assert conversation_history[0] == {"role": "user", "content": "message 5"}
|
|
assert conversation_history[-2] == {"role": "user", "content": "message 13"}
|
|
assert conversation_history[-1]["role"] == "assistant"
|
|
assert conversation_history[-1]["content"].endswith("...")
|
|
assert len(conversation_history[-1]["content"]) <= 1200
|
|
|
|
|
|
def test_slide_edit_agent_prompt_limits_scratchpad_entries():
|
|
service = SlideEditAgentService()
|
|
request = _tool_request()
|
|
runner = SlideEditToolRunner(SlideEditAgentContext.from_request(request))
|
|
scratchpad = [
|
|
{"iteration": index, "observation": {"summary": f"result {index}"}}
|
|
for index in range(25)
|
|
]
|
|
|
|
prompt = service._build_prompt(
|
|
request, runner, scratchpad=scratchpad, max_iterations=100
|
|
)
|
|
context = json.loads(prompt.split("\n\n", 1)[1])
|
|
|
|
assert context["max_iterations"] == 100
|
|
assert len(context["scratchpad"]) == 21
|
|
assert context["scratchpad"][0]["note"].startswith("5 earlier scratchpad")
|
|
assert context["scratchpad"][1]["iteration"] == 5
|
|
|
|
|
|
class _FakePPTService:
|
|
def __init__(self, responses):
|
|
self.responses = list(responses)
|
|
self.calls = []
|
|
|
|
async def _chat_completion_for_role(self, role, messages, **kwargs):
|
|
self.calls.append((role, messages, kwargs))
|
|
return SimpleNamespace(content=self.responses.pop(0))
|
|
|
|
|
|
class _FakeNativeToolPPTService:
|
|
def __init__(self, responses):
|
|
self.responses = list(responses)
|
|
self.calls = []
|
|
|
|
async def _chat_completion_for_role(self, role, messages, **kwargs):
|
|
self.calls.append((role, messages, kwargs))
|
|
return self.responses.pop(0)
|
|
|
|
|
|
class _NoNativeToolPPTService(_FakePPTService):
|
|
async def _chat_completion_for_role(self, role, messages, **kwargs):
|
|
if kwargs.get("tools"):
|
|
raise RuntimeError("unsupported parameter: tools")
|
|
return await super()._chat_completion_for_role(role, messages, **kwargs)
|
|
|
|
|
|
class _FailingPPTService:
|
|
async def _chat_completion_for_role(self, role, messages, **kwargs):
|
|
raise RuntimeError("model unavailable")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slide_edit_agent_uses_native_tool_calls_before_text_fallback():
|
|
service = SlideEditAgentService()
|
|
fake_ppt = _FakeNativeToolPPTService(
|
|
[
|
|
SimpleNamespace(
|
|
content="Inspecting.",
|
|
tool_calls=[
|
|
{
|
|
"id": "call-1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "update_text",
|
|
"arguments": json.dumps({"selector": "h1", "text": "Short Title"}),
|
|
},
|
|
}
|
|
],
|
|
),
|
|
SimpleNamespace(content="Shortened the title.", tool_calls=[]),
|
|
]
|
|
)
|
|
|
|
proposal = await service.run_agent(_tool_request(), fake_ppt)
|
|
|
|
assert proposal.summary == "Shortened the title."
|
|
assert "Short Title" in proposal.html_content
|
|
first_role, _first_messages, first_kwargs = fake_ppt.calls[0]
|
|
assert first_role == "editor"
|
|
assert first_kwargs["tool_choice"] == "auto"
|
|
assert first_kwargs["parallel_tool_calls"] is False
|
|
assert first_kwargs["tools"][0]["type"] == "function"
|
|
assert first_kwargs["tools"][0]["function"]["parameters"]["type"] == "object"
|
|
second_messages = fake_ppt.calls[1][1]
|
|
assert second_messages[-1].role.value == "tool"
|
|
assert second_messages[-1].tool_call_id == "call-1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slide_edit_agent_native_text_action_keeps_observation_in_history():
|
|
service = SlideEditAgentService()
|
|
fake_ppt = _FakeNativeToolPPTService(
|
|
[
|
|
SimpleNamespace(
|
|
content=json.dumps(
|
|
{
|
|
"thought": "Inspect before editing.",
|
|
"action": "inspect_slide_html",
|
|
"action_input": {},
|
|
}
|
|
),
|
|
tool_calls=[],
|
|
),
|
|
SimpleNamespace(
|
|
content=json.dumps(
|
|
{
|
|
"thought": "Use the inspected title.",
|
|
"action": "final",
|
|
"action_input": {"summary": "Inspected the slide."},
|
|
}
|
|
),
|
|
tool_calls=[],
|
|
),
|
|
]
|
|
)
|
|
|
|
proposal = await service.run_agent(_tool_request(), fake_ppt)
|
|
|
|
assert proposal.summary == "Inspected the slide."
|
|
second_messages = fake_ppt.calls[1][1]
|
|
assert second_messages[-1].role.value == "user"
|
|
assert "Tool result:" in second_messages[-1].content
|
|
assert "Long Original Title" in second_messages[-1].content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slide_edit_agent_runs_tools_and_returns_proposal():
|
|
service = SlideEditAgentService()
|
|
fake_ppt = _FakePPTService(
|
|
[
|
|
json.dumps(
|
|
{
|
|
"thought": "Inspect before editing.",
|
|
"action": "inspect_slide_html",
|
|
"action_input": {"slide_index": 1},
|
|
}
|
|
),
|
|
json.dumps(
|
|
{
|
|
"thought": "Update the title text.",
|
|
"action": "update_text",
|
|
"action_input": {"selector": "h1", "text": "Short Title"},
|
|
}
|
|
),
|
|
json.dumps(
|
|
{
|
|
"thought": "The title is shorter and ready.",
|
|
"action": "final",
|
|
"action_input": {"summary": "Shortened the title."},
|
|
}
|
|
),
|
|
]
|
|
)
|
|
events = []
|
|
|
|
async def capture(event):
|
|
events.append(event)
|
|
|
|
proposal = await service.run_agent(_tool_request(), fake_ppt, capture)
|
|
|
|
assert proposal.summary == "Shortened the title."
|
|
assert "Short Title" in proposal.html_content
|
|
assert proposal.validation.valid is True
|
|
assert [event["type"] for event in events if event["type"] == "tool_call"] == [
|
|
"tool_call",
|
|
"tool_call",
|
|
]
|
|
assert {event["type"] for event in events} >= {
|
|
"agent_start",
|
|
"agent_step",
|
|
"tool_result",
|
|
"draft_ready",
|
|
"needs_confirmation",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slide_edit_agent_retries_initial_unstructured_text_response():
|
|
service = SlideEditAgentService()
|
|
fake_ppt = _NoNativeToolPPTService(
|
|
[
|
|
"I will make the title shorter.",
|
|
json.dumps(
|
|
{
|
|
"thought": "Use a structured tool call.",
|
|
"action": "update_text",
|
|
"action_input": {"selector": "h1", "text": "Short Title"},
|
|
}
|
|
),
|
|
json.dumps(
|
|
{
|
|
"thought": "The title is shorter.",
|
|
"action": "final",
|
|
"action_input": {"summary": "Shortened the title."},
|
|
}
|
|
),
|
|
]
|
|
)
|
|
|
|
proposal = await service.run_agent(_tool_request(maxIterations=3), fake_ppt)
|
|
|
|
assert proposal.summary == "Shortened the title."
|
|
assert "Short Title" in proposal.html_content
|
|
second_prompt = fake_ppt.calls[1][1][-1].content
|
|
second_context = json.loads(second_prompt.split("\n\n", 1)[1])
|
|
assert "Response was not a valid JSON action" in second_context["scratchpad"][0]["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slide_edit_agent_allows_unstructured_final_after_tool_execution():
|
|
service = SlideEditAgentService()
|
|
fake_ppt = _NoNativeToolPPTService(
|
|
[
|
|
json.dumps(
|
|
{
|
|
"thought": "Update the title text.",
|
|
"action": "update_text",
|
|
"action_input": {"selector": "h1", "text": "Short Title"},
|
|
}
|
|
),
|
|
"Shortened the title.",
|
|
]
|
|
)
|
|
|
|
proposal = await service.run_agent(_tool_request(maxIterations=3), fake_ppt)
|
|
|
|
assert proposal.summary == "Shortened the title."
|
|
assert "Short Title" in proposal.html_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slide_edit_agent_reports_unsupported_tool_and_continues():
|
|
service = SlideEditAgentService()
|
|
fake_ppt = _FakePPTService(
|
|
[
|
|
json.dumps(
|
|
{
|
|
"thought": "Try an unknown tool.",
|
|
"action": "bad_tool",
|
|
"action_input": {},
|
|
}
|
|
),
|
|
json.dumps(
|
|
{
|
|
"thought": "Finish without change.",
|
|
"action": "final",
|
|
"action_input": {"summary": "No safe change."},
|
|
}
|
|
),
|
|
]
|
|
)
|
|
|
|
proposal = await service.run_agent(_tool_request(maxIterations=3), fake_ppt)
|
|
|
|
assert proposal.summary == "No safe change."
|
|
assert proposal.tool_transcript == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slide_edit_agent_finalizes_when_max_iterations_is_reached():
|
|
service = SlideEditAgentService()
|
|
fake_ppt = _FakePPTService(
|
|
[
|
|
json.dumps(
|
|
{
|
|
"thought": "Inspect.",
|
|
"action": "inspect_slide_html",
|
|
"action_input": {},
|
|
}
|
|
),
|
|
json.dumps(
|
|
{
|
|
"thought": "Inspect again.",
|
|
"action": "inspect_slide_html",
|
|
"action_input": {},
|
|
}
|
|
),
|
|
]
|
|
)
|
|
|
|
proposal = await service.run_agent(_tool_request(maxIterations=2), fake_ppt)
|
|
|
|
assert (
|
|
proposal.summary
|
|
== "Reached the maximum edit iterations and prepared the current draft."
|
|
)
|
|
assert proposal.validation.valid is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slide_edit_agent_emits_error_event_when_model_fails():
|
|
service = SlideEditAgentService()
|
|
events = []
|
|
|
|
async def capture(event):
|
|
events.append(event)
|
|
|
|
with pytest.raises(RuntimeError, match="model unavailable"):
|
|
await service.run_agent(_tool_request(), _FailingPPTService(), capture)
|
|
|
|
error_events = [event for event in events if event["type"] == "error"]
|
|
assert error_events == [
|
|
{
|
|
"type": "error",
|
|
"phase": "model",
|
|
"message": "model unavailable",
|
|
"errorType": "RuntimeError",
|
|
"iteration": 1,
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slide_edit_agent_emits_error_event_when_tool_raises(monkeypatch):
|
|
service = SlideEditAgentService()
|
|
fake_ppt = _FakePPTService(
|
|
[
|
|
json.dumps(
|
|
{
|
|
"thought": "Try to inspect.",
|
|
"action": "inspect_slide_html",
|
|
"action_input": {},
|
|
}
|
|
)
|
|
]
|
|
)
|
|
events = []
|
|
|
|
async def capture(event):
|
|
events.append(event)
|
|
|
|
async def fail_tool(self, tool_name, tool_input):
|
|
raise ValueError("tool exploded")
|
|
|
|
monkeypatch.setattr(SlideEditToolRunner, "execute_tool", fail_tool)
|
|
|
|
with pytest.raises(ValueError, match="tool exploded"):
|
|
await service.run_agent(_tool_request(), fake_ppt, capture)
|
|
|
|
error_events = [event for event in events if event["type"] == "error"]
|
|
assert error_events == [
|
|
{
|
|
"type": "error",
|
|
"phase": "tool",
|
|
"message": "tool exploded",
|
|
"errorType": "ValueError",
|
|
"iteration": 1,
|
|
"tool": "inspect_slide_html",
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slide_edit_agent_vision_mode_sends_multimodal_context():
|
|
service = SlideEditAgentService()
|
|
screenshot = "data:image/png;base64,slide-shot"
|
|
reference_url = "https://example.test/reference.png"
|
|
fake_ppt = _FakePPTService(
|
|
[
|
|
json.dumps(
|
|
{
|
|
"thought": "Vision context is enough.",
|
|
"action": "final",
|
|
"action_input": {"summary": "Checked visual context."},
|
|
}
|
|
)
|
|
]
|
|
)
|
|
|
|
proposal = await service.run_agent(
|
|
_tool_request(
|
|
visionEnabled=True,
|
|
slideScreenshot=screenshot,
|
|
images=[{"name": "Reference", "size": "120KB", "url": reference_url}],
|
|
),
|
|
fake_ppt,
|
|
)
|
|
|
|
assert proposal.summary == "Checked visual context."
|
|
role, messages, _kwargs = fake_ppt.calls[0]
|
|
assert role == "vision_analysis"
|
|
user_content = messages[-1].content
|
|
assert isinstance(user_content, list)
|
|
assert isinstance(user_content[0], TextContent)
|
|
assert '"vision"' in user_content[0].text
|
|
assert "slide_screenshot" in user_content[0].text
|
|
assert "Reference" in user_content[0].text
|
|
assert screenshot not in user_content[0].text
|
|
image_parts = [part for part in user_content if isinstance(part, ImageContent)]
|
|
assert [part.image_url["url"] for part in image_parts] == [
|
|
screenshot,
|
|
reference_url,
|
|
]
|
|
|
|
|
|
def test_slide_edit_agent_tool_schemas_match_runner_tool_names():
|
|
service = SlideEditAgentService()
|
|
runner = SlideEditToolRunner(_tool_context())
|
|
|
|
schema_names = [schema["name"] for schema in service._tool_schemas(runner)]
|
|
|
|
assert schema_names == runner.available_tool_names()
|