assistant/tests/test_prospecting_tools.py

792 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Prospecting evidence must come from the active page, within task limits."""
import json
import base64
import asyncio
from types import SimpleNamespace
import pytest
from backend.browser import tools as subject
from backend.browser import runner
NOTE_A = "65a1234567890abcdef12345"
NOTE_B = "65b1234567890abcdef12345"
URL_A = f"https://www.xiaohongshu.com/explore/{NOTE_A}"
URL_B = f"https://www.xiaohongshu.com/explore/{NOTE_B}"
class ObservedPage:
def __init__(self, url=URL_A, text="原帖:想找装修公司。评论小明:我也想找靠谱团队。", metadata=None):
self.url = url
self.text = text
self.error = None
self.observed_selectors = []
self.metadata = metadata or {}
async def evaluate(self, code, selector):
self.observed_selectors.append(selector)
if self.error:
return json.dumps({"error": self.error})
# Browser Use 0.13.10 Page.evaluate serializes objects to a JSON string.
return json.dumps({"url": self.url, "text": self.text, **self.metadata}, ensure_ascii=False)
async def get_url(self):
return self.url
class ObservedSession:
def __init__(self, page=None):
self.page = page or ObservedPage()
self.agent_focus_target_id = None
self.cdp_client = SimpleNamespace()
async def get_current_page(self):
return self.page
async def get_current_page_url(self):
return self.page.url
@pytest.fixture
def context(tmp_path, monkeypatch):
monkeypatch.setattr(subject, "task_doc_dir", lambda _task_id: tmp_path)
monkeypatch.setattr(runner, "task_doc_dir", lambda _task_id: tmp_path)
monkeypatch.setattr(runner, "append_progress", lambda *args, **kwargs: None)
subject.clear_task_context("test_prospecting")
subject.set_prospecting_context("test_prospecting", max_notes=2, max_comments_per_note=1)
yield "test_prospecting", tmp_path
subject.clear_task_context("test_prospecting")
async def save(task_id, session=None, **overrides):
return await subject._save_source(task_id, session or ObservedSession(), **{
"source_type": "note", "source_url": URL_A, "content": "想找装修公司。", "source_selector": "#note-body", **overrides,
})
def test_profile_has_no_school_or_unsafe_generic_actions(context):
registered = subject.build_prospecting_tools(context[0]).registry.registry.actions
assert {"ask_user", "save_source", "save_lead", "save_learning", "report_learning_outcome"} <= registered.keys()
for name in ["report_blocker", "save_artifact", *subject.PROSPECTING_EXCLUDED_ACTIONS]:
assert name not in registered
assert "browser_session" not in registered["save_source"].param_model.model_fields
assert registered["save_source"].param_model.model_fields["source_url"].default == ""
assert registered["save_lead"].param_model.model_fields["source_url"].default == ""
assert "inspect_source_candidates" in registered
@pytest.mark.parametrize("enabled", [False, True])
def test_reply_relation_tools_are_an_explicit_opt_in_in_model_action_schema(context, monkeypatch, enabled):
monkeypatch.setattr(subject.settings, "reply_relation_preflight_enabled", enabled)
tools = subject.build_prospecting_tools(context[0])
schema = tools.registry.create_action_model().model_json_schema()
# Browser-use 0.13.10 exposes a root union of individual action models.
fields = {name for model in schema["$defs"].values() for name in model.get("properties", {})}
for name in ("inspect_reply_relations", "resolve_reply_target"):
assert (name in tools.registry.registry.actions) is enabled
assert (name in fields) is enabled
assert {"inspect_source_candidates", "save_source", "advance_comments", "expand_replies"} <= fields
def test_explicit_exclusion_still_wins_when_relation_feature_is_enabled(context, monkeypatch):
monkeypatch.setattr(subject.settings, "reply_relation_preflight_enabled", True)
tools = subject.build_prospecting_tools(context[0], exclude=["inspect_reply_relations", "resolve_reply_target"])
schema = tools.registry.create_action_model().model_json_schema()
fields = {name for model in schema["$defs"].values() for name in model.get("properties", {})}
assert not {"inspect_reply_relations", "resolve_reply_target"}.intersection(fields)
def test_actual_search_ai_url_records_bounded_decoded_coverage_only(context):
task_id, directory = context
urls = ["https://www.xiaohongshu.com/search_result_ai?keyword=%25E6%259D%25AD%25E5%25B7%259E%25E8%25B7%259F%25E5%25A6%2586",
"https://www.xiaohongshu.com/search_result?keyword=杭州新娘跟妆",
"https://www.xiaohongshu.com/search_result_ai?keyword=杭州跟妆"]
for url in urls:
subject.record_navigation_observation(task_id, url)
artifacts = subject.get_task_artifacts(task_id)
assert artifacts["coverage"]["search_keywords_observed"] == ["杭州跟妆", "杭州新娘跟妆"]
assert not artifacts.get("sources")
assert json.loads((directory / "evidence.json").read_text())["coverage"]["search_keywords_observed"] == ["杭州跟妆", "杭州新娘跟妆"]
@pytest.mark.parametrize("url", [
"https://www.xiaohongshu.com.evil.test/search_result_ai?keyword=杭州",
"https://www.xiaohongshu.com/explore/example?keyword=杭州",
"file://www.xiaohongshu.com/search_result_ai?keyword=杭州",
"https://www.xiaohongshu.com/search_result_ai?keyword=%250A杭州",
"https://www.xiaohongshu.com/search_result_ai?keyword=" + "a" * 81,
])
def test_non_search_or_invalid_keyword_is_not_coverage(context, url):
assert subject.observed_search_keyword(url) is None
async def test_source_is_checked_and_checkpointed_with_actual_url(context):
task_id, directory = context
session = ObservedSession(ObservedPage(url=URL_A + "?xsec_source=pc_search"))
entry, created = await save(task_id, session)
assert created and entry["source_type"] == "note"
assert entry["source_url"] == session.page.url
assert entry["status"] == "observed" and entry["observed_at"]
snapshot = json.loads((directory / "evidence.json").read_text())
assert snapshot["sources"] == [entry]
assert json.loads((directory / "sources.json").read_text()) == [entry]
assert not list(directory.glob(".evidence-*"))
assert "leads" not in subject.get_task_artifacts(task_id)
async def test_omitted_source_url_uses_actual_page_without_exposing_signed_url_to_model(context):
task_id, directory = context
actual_url = URL_A + "?xsec_token=fixture-token&xsec_source=pc_search"
session = ObservedSession(ObservedPage(url=actual_url))
registry = subject.build_prospecting_tools(task_id).registry
result = await registry.execute_action("save_source", {
"source_type": "note", "content": "想找装修公司。", "source_selector": "#note-body",
}, browser_session=session)
assert "xsec_token" not in result.extracted_content
assert "fixture-token" not in result.extracted_content
assert json.loads((directory / "evidence.json").read_text())["sources"][0]["source_url"] == actual_url
class CandidatePage(ObservedPage):
def __init__(self, url=URL_A, *, candidates=None, error=None):
super().__init__(url=url)
self.candidates = candidates if candidates is not None else [
{"source_type": "note", "source_selector": "#detail-desc", "content": "想找装修公司。"},
{"source_type": "comment", "source_selector": "#noteContainer > div:nth-child(2) > span:nth-child(1)", "content": "我也想找靠谱团队。"},
]
self.inspection_error = error
self.inspections = 0
async def evaluate(self, code, maximum):
self.inspections += 1
if self.inspection_error:
return json.dumps({"error": self.inspection_error})
return json.dumps({"url": self.url, "candidates": self.candidates})
async def test_candidate_inspector_returns_bounded_scoped_text_without_current_url(context):
page = CandidatePage(url=URL_A + "?xsec_token=fixture-secret")
result = await subject._inspect_source_candidates(ObservedSession(page), max_comments=1)
assert result["note_id"] == NOTE_A
assert len(result["candidates"]) == 2
assert all(item["coverage"] == "visible_excerpt" for item in result["candidates"])
assert result["scope"] == "current_viewport_only" and result["comments_complete"] is False
assert "fixture-secret" not in json.dumps(result)
assert "url" not in result
assert page.inspections == 1
async def test_inspector_comment_bound_is_visible_in_model_schema_and_validated_before_dom(context):
registry = subject.build_prospecting_tools(context[0]).registry
action = registry.registry.actions["inspect_source_candidates"]
schema = action.param_model.model_json_schema()["properties"]["max_comments"]
assert schema["type"] == "integer"
assert schema["minimum"] == 0 and schema["maximum"] == 5
assert schema["default"] == 5
assert "不是本轮" in schema["description"]
page = CandidatePage()
with pytest.raises(RuntimeError, match="Invalid parameters"):
await registry.execute_action("inspect_source_candidates", {"max_comments": 10}, browser_session=ObservedSession(page))
assert page.inspections == 0
async def test_candidate_inspector_stops_before_dom_read_on_search_or_non_note(context):
page = CandidatePage(url="https://www.xiaohongshu.com/search_result?keyword=x")
with pytest.raises(ValueError, match="具体笔记"):
await subject._inspect_source_candidates(ObservedSession(page))
assert page.inspections == 0
@pytest.mark.parametrize("case", ["too_many", "duplicate_note", "hidden_root", "six_comments"])
async def test_candidate_inspector_refuses_invalid_or_over_budget_observations(context, case):
page = CandidatePage()
maximum = 1
if case == "too_many":
page.candidates *= 3
elif case == "duplicate_note":
page.candidates = [page.candidates[0], page.candidates[0]]
elif case == "hidden_root":
page.inspection_error = "note_root_not_visible"
else:
maximum = 6
with pytest.raises(ValueError):
await subject._inspect_source_candidates(ObservedSession(page), maximum)
@pytest.mark.parametrize("overrides,message", [
({"content": "作者正在找杭州的装修公司"}, "原文未在"),
({"source_url": URL_B}, "实际页面不一致"),
({"source_url": "https://www.xiaohongshu.com.evil.test/explore/" + NOTE_A}, "HTTPS 笔记"),
({"source_url": "https://www.xiaohongshu.com/search_result?keyword=x"}, "具体笔记"),
])
async def test_unobserved_or_misattributed_source_is_rejected(context, overrides, message):
with pytest.raises(ValueError, match=message):
await save(context[0], **overrides)
assert subject.get_task_artifacts(context[0]).get("sources", []) == []
async def test_actual_page_identity_is_checked_independently(context):
with pytest.raises(ValueError, match="HTTPS 笔记"):
await save(context[0], ObservedSession(ObservedPage(url="app://ui/index.html")))
async def test_comments_need_separate_container_and_saved_note(context):
task_id, _ = context
with pytest.raises(ValueError, match="独立来源容器"):
await save(task_id, source_type="comment", content="我也想找靠谱团队。", source_selector="")
with pytest.raises(ValueError, match="先保存这篇笔记正文"):
await save(task_id, source_type="comment", content="我也想找靠谱团队。", source_selector="#comment-1")
await save(task_id)
session = ObservedSession(ObservedPage(text="小明:我也想找靠谱团队。"))
comment, created = await save(
task_id, session, source_type="comment", content="我也想找靠谱团队。",
author="小明", source_selector="#comment-1",
)
assert created and comment["source_type"] == "comment"
assert session.page.observed_selectors == ["#comment-1"]
async def test_duplicate_does_not_consume_saved_source_budget(context):
task_id, _ = context
first, _ = await save(task_id)
again, created = await save(task_id)
assert not created and again["source_id"] == first["source_id"]
with pytest.raises(ValueError, match="不同摘录重复计数"):
await save(task_id, content="原帖:想找装修公司。")
async def test_comment_budget_is_per_note_and_notes_have_a_total_cap(context):
task_id, _ = context
await save(task_id)
await save(task_id, source_type="comment", content="我也想找靠谱团队。", source_selector="#first")
with pytest.raises(ValueError, match="用户明确指定.*评论"):
await save(task_id, source_type="comment", content="评论小明", source_selector="#second")
session = ObservedSession(ObservedPage(url=URL_B))
await save(task_id, session, source_url=URL_B)
await save(task_id, session, source_url=URL_B, source_type="comment", content="我也想找靠谱团队。", source_selector="#other-note")
session.page.url = "https://www.xiaohongshu.com/explore/65c1234567890abcdef12345"
with pytest.raises(ValueError, match="用户明确指定.*笔记"):
await save(task_id, session, source_url=session.page.url)
async def test_non_unique_or_hidden_dom_source_cannot_be_saved(context):
session = ObservedSession()
session.page.error = "source_not_unique"
with pytest.raises(ValueError, match="来源容器未通过"):
await save(context[0], session)
async def test_learning_draft_needs_actual_page_evidence(context):
task_id, directory = context
registry = subject.build_prospecting_tools(task_id).registry
session = ObservedSession(ObservedPage(text="搜索笔记和用户"))
result = await registry.execute_action("save_learning", {
"key": "搜索入口", "value": "在搜索框输入关键词并回车", "category": "control_tip", "evidence": "搜索笔记和用户",
}, browser_session=session)
assert "草稿" in result.extracted_content
candidate = subject.get_task_artifacts(task_id)["learning_candidates"][0]
assert candidate["status"] == "draft"
assert json.loads((directory / "evidence.json").read_text())["learning_candidates"] == [candidate]
with pytest.raises(RuntimeError, match="依据未在"):
await registry.execute_action("save_learning", {
"key": "不存在的按钮", "value": "点击快速采集", "category": "control_tip", "evidence": "快速采集",
}, browser_session=session)
async def test_personal_contact_is_not_an_operation_memory(context):
registry = subject.build_prospecting_tools(context[0]).registry
with pytest.raises(RuntimeError, match="联系方式"):
await registry.execute_action("save_learning", {
"key": "用户信息", "value": "下次联系13812345678", "category": "note", "evidence": "原帖",
}, browser_session=ObservedSession())
async def test_confirmed_learning_outcome_needs_page_evidence(context):
task_id, directory = context
registry = subject.build_prospecting_tools(task_id).registry
session = ObservedSession()
with pytest.raises(RuntimeError, match="逐字证据"):
await registry.execute_action("report_learning_outcome", {
"entry_id": 5, "outcome": "confirmed", "note": "我觉得是这样的",
}, browser_session=session)
await registry.execute_action("report_learning_outcome", {
"entry_id": 5, "outcome": "confirmed", "note": "页面有此文字", "evidence": "原帖",
}, browser_session=session)
outcomes = json.loads((directory / "evidence.json").read_text())["learning_outcomes"]
assert outcomes == [{"entry_id": 5, "outcome": "confirmed", "note": "页面有此文字", "evidence": "原帖"}]
def test_hydrated_evidence_is_independent_and_limits_validate(context):
task_id, _ = context
saved = {"sources": [{"source_id": "stored"}]}
subject.set_prospecting_context(task_id, artifacts=saved)
saved["sources"].clear()
assert len(subject.get_task_artifacts(task_id)["sources"]) == 1
subject.set_prospecting_context(task_id, max_notes=500, max_comments_per_note=200)
assert subject._prospecting_limits[task_id] == {"max_notes": 500, "max_comments_per_note": 200}
with pytest.raises(ValueError):
subject.set_prospecting_context(task_id, max_notes=0)
subject.clear_task_context(task_id)
assert task_id not in subject._prospecting_limits
async def test_unverified_optional_author_does_not_block_valid_excerpt(context):
entry, created = await save(context[0], author="不存在的作者")
assert created and entry["author"] == ""
assert entry["metadata_warnings"]
async def test_author_is_observed_in_corresponding_outer_region_not_guessed_from_quote(context):
page = ObservedPage(text="想找装修公司。", metadata={
"source_type": "note", "author": "页面作者", "author_profile_url": PROFILE_A,
"page_time": "昨天 15:05", "ip_location": "浙江",
})
entry, _ = await save(context[0], ObservedSession(page), author="模型猜的昵称")
assert entry["author"] == "页面作者"
assert entry["author_profile_url"] == PROFILE_A
assert entry["page_time"] == "昨天 15:05" and entry["ip_location"] == "浙江"
assert "region" not in entry
async def test_replies_keep_thread_identity_and_speaker_separate(context):
task_id, directory = context
subject.set_prospecting_context(task_id)
await save(task_id)
root, _ = await save(task_id, ObservedSession(ObservedPage(text="我在杭州装修", metadata={
"source_type": "comment", "author": "甲", "author_profile_url": PROFILE_A,
"ip_location": "浙江",
})), source_type="comment", content="我在杭州装修", source_selector="#root-comment")
reply, _ = await save(task_id, ObservedSession(ObservedPage(text="想咨询下价格", metadata={
"source_type": "reply", "author": "乙", "parent_source_selector": "#root-comment",
"root_source_selector": "#root-comment", "reply_to": "甲",
})), source_type="reply", content="想咨询下价格", source_selector="#reply-1")
assert reply["parent_source_id"] == reply["root_source_id"] == root["source_id"]
assert reply["author"] == "乙" and reply["reply_to"] == "甲"
assert reply["ip_location"] == reply["author_profile_url"] == ""
assert len(json.loads((directory / "evidence.json").read_text())["sources"]) == 3
async def test_reply_type_must_be_observed_and_cannot_impersonate_root(context):
await save(context[0])
with pytest.raises(ValueError, match="层级"):
await save(context[0], ObservedSession(ObservedPage(metadata={"source_type": "reply"})),
source_type="comment", source_selector="#reply")
with pytest.raises(ValueError, match="回复必须"):
await save(context[0], source_type="reply", source_selector="#reply")
async def test_identical_comments_from_different_speakers_are_not_deduplicated(context):
subject.set_prospecting_context(context[0])
await save(context[0])
first, _ = await save(context[0], source_type="comment", content="我也想找靠谱团队。", source_selector="#speaker-a")
second, created = await save(context[0], source_type="comment", content="我也想找靠谱团队。", source_selector="#speaker-b")
assert created and first["source_id"] != second["source_id"]
async def test_default_scope_has_no_note_or_comment_business_quota(context):
task_id, _ = context
subject.set_prospecting_context(task_id)
for i in range(7):
url = "https://www.xiaohongshu.com/explore/" + f"{i + 1:024x}"
page = ObservedPage(url=url)
await save(task_id, ObservedSession(page), source_url=url)
if i == 0:
for comment in range(12):
await save(task_id, ObservedSession(page), source_url=url, source_type="comment",
content="我也想找靠谱团队。", source_selector=f"#comment-{comment}")
sources = subject.get_task_artifacts(task_id)["sources"]
assert sum(item["source_type"] == "note" for item in sources) == 7
assert sum(item["source_type"] == "comment" for item in sources) == 12
async def test_incremental_inspector_reports_only_new_sources_and_empty_reason(context):
task_id, _ = context
subject.set_prospecting_context(task_id)
await save(task_id, source_selector="#detail-desc")
selector = "#noteContainer > div:nth-child(2) > span:nth-child(1)"
await save(task_id, source_type="comment", content="我也想找靠谱团队。", source_selector=selector)
page = CandidatePage()
empty = await subject._inspect_source_candidates(ObservedSession(page), task_id=task_id)
assert empty["candidates"] == [] and empty["new_count"] == 0 and empty["saved_count"] == 2
assert empty["empty_reason"] == "visible_sources_already_saved"
page.candidates.append({"source_type": "reply", "source_selector": "#new-reply", "content": "多少钱?"})
result = await subject._inspect_source_candidates(ObservedSession(page), task_id=task_id)
assert result["new_count"] == 1 and result["candidates"][0]["source_type"] == "reply"
assert result["comments_complete"] is False
PROFILE_A = "https://www.xiaohongshu.com/user/profile/66a1234567890abcdef12345"
PROFILE_B = "https://www.xiaohongshu.com/user/profile/66b1234567890abcdef12345"
class ProfilePage(ObservedPage):
def __init__(self, url=PROFILE_A, **fields):
super().__init__(url=url)
self.fields = {"nickname": "甲", "xiaohongshu_id": "user_visible_123", "identity_label": "小红书号:user_visible_123", **fields}
async def evaluate(self, _code):
return json.dumps({"url": self.url, **self.fields}, ensure_ascii=False)
async def test_profile_requires_matching_observed_author_link_and_visible_id_label(context):
task_id, directory = context
source, _ = await save(task_id, ObservedSession(ObservedPage(metadata={
"author": "甲", "author_profile_url": PROFILE_A,
})))
with pytest.raises(ValueError, match="不一致"):
await subject._inspect_profile(task_id, ObservedSession(ProfilePage(PROFILE_B)), source["source_id"])
with pytest.raises(ValueError, match="标签依据"):
await subject._inspect_profile(task_id, ObservedSession(ProfilePage(identity_label="")), source["source_id"])
profile = await subject._inspect_profile(task_id, ObservedSession(ProfilePage()), source["source_id"])
assert profile["xiaohongshu_id"] == "user_visible_123"
assert profile["profile_id"] != profile["xiaohongshu_id"]
assert source["xiaohongshu_id"] == profile["xiaohongshu_id"]
assert json.loads((directory / "evidence.json").read_text())["profiles"] == [profile]
async def test_profile_without_observed_source_avatar_link_stays_unattributed(context):
source, _ = await save(context[0])
with pytest.raises(ValueError, match="尚未核对作者头像"):
await subject._inspect_profile(context[0], ObservedSession(ProfilePage()), source["source_id"])
assert not subject.get_task_artifacts(context[0]).get("profiles")
class ExpansionPage(CandidatePage):
def __init__(self, *, loads_reply=True):
super().__init__()
self.loads_reply = loads_reply
self.clicks = 0
async def evaluate(self, code, options):
if code == subject._OBSERVE_REPLY_CONTROL:
return json.dumps({"url": self.url, "text": "展开 1 条回复",
"fingerprint": "root" + ("loaded reply" if self.clicks and self.loads_reply else ""),
"click_point": {"x": 100, "y": 80, "viewport_width": 400, "viewport_height": 600},
"reply_count": int(bool(self.clicks and self.loads_reply))})
result = [*self.candidates]
if self.clicks and self.loads_reply:
result.append({"source_type": "reply", "source_selector": "#actual-reply", "content": "我想问价格"})
return json.dumps({"url": self.url, "candidates": result})
async def get_elements_by_css_selector(self, _selector):
async def click():
raise AssertionError("Expansion must not use an implicit-scrolling element click")
return [SimpleNamespace(click=click)]
@property
async def mouse(self):
async def click(**point):
assert point == {"x": 100, "y": 80}
self.clicks += 1
return SimpleNamespace(click=click)
async def test_expand_uses_one_native_click_and_confirms_new_reply(context):
page = ExpansionPage()
result = await subject._expand_replies(context[0], ObservedSession(page), "#expand")
assert result["status"] == "expanded" and result["new_visible_replies"] == 1
assert result["comments_complete"] is False and page.clicks == 1
async def test_verbatim_reply_excerpt_keeps_program_full_container_fingerprint(context):
task_id, _ = context
await save(task_id)
full = "回复: 原始的完整内容。\n包括足够上下文"
page = ObservedPage(text=full, metadata={"source_type": "reply", "source_key": "stable-reply"})
entry, added = await save(task_id, ObservedSession(page), source_type="reply", source_selector="#actual", content="原始的完整内容")
assert added
pre = subject.get_task_artifacts(task_id)["operation_evidence"][-1]["details"]["pre_snapshot"]
import hashlib
expected = hashlib.sha256(f"{NOTE_A}\n#actual\n{subject._compact_text(full)}".encode()).hexdigest()
assert pre["container_fingerprint"] == expected
assert "container_fingerprint" not in entry
@pytest.mark.parametrize("enabled", [False, True])
@pytest.mark.parametrize("parent_selector", ["", "#not-yet-saved-parent"])
async def test_saved_reply_with_unknown_parent_is_successful_without_parent_repair_guidance(
context, monkeypatch, enabled, parent_selector,
):
task_id, directory = context
monkeypatch.setattr(subject.settings, "reply_relation_preflight_enabled", enabled)
await save(task_id)
page = ObservedPage(text="我需要装修方案", metadata={"source_type": "reply", "source_key": "reply-key",
"parent_resolution": "target_identity_missing", "parent_source_selector": parent_selector,
"root_source_selector": parent_selector, "reply_to_selector": "#reply-to"})
registry = subject.build_prospecting_tools(task_id).registry
result = await registry.execute_action("save_source", {
"source_type": "reply", "content": "我需要装修方案", "source_selector": "#actual-reply",
}, browser_session=ObservedSession(page))
receipt = json.loads(result.extracted_content)
fact = subject.get_task_artifacts(task_id)["operation_evidence"][-1]
assert fact["outcome"] == "success"
assert fact["verifier"] == "source_container_quote_and_persisted"
assert fact["details"]["pre_snapshot"]["quote_matched"] is True
assert fact["details"]["post_snapshot"]["persisted"] is True
assert fact["details"]["post_snapshot"]["relations_checked"] is False
assert receipt["relations_checked"] is False
assert receipt["parent_resolution"] == "target_identity_missing"
assert receipt["parent_source_id"] is None and receipt["root_source_id"] is None
assert receipt["parent_source_selector"] == parent_selector
assert receipt["reply_to_selector"] == "#reply-to"
for instruction in ("先检查并保存", "重新保存", "resolve_reply_target"):
assert instruction not in receipt["next_action"]
snapshot = json.loads((directory / "evidence.json").read_text())
assert snapshot["operation_evidence"][-1] == fact
assert snapshot["sources"][-1]["parent_resolution"] == "target_identity_missing"
assert snapshot["sources"][-1]["content"] == "我需要装修方案"
async def test_no_progress_expand_is_not_clicked_repeatedly(context):
page = ExpansionPage(loads_reply=False)
first = await subject._expand_replies(context[0], ObservedSession(page), "#expand")
assert first["status"] == "no_new_content"
with pytest.raises(ValueError, match="不重复点击"):
await subject._expand_replies(context[0], ObservedSession(page), "#expand")
assert page.clicks == 1
class OwnedExpansionPage(ExpansionPage):
"""A native-click page with independently observable parent-discussion facts."""
def __init__(self, *, loads_reply=True, owner_delta=1, owner_error=False, owner_identity="parent-comment",
owner_url=URL_A, loading_change=False, reply_elsewhere=False):
super().__init__(loads_reply=loads_reply)
self.owner_delta = owner_delta
self.owner_error = owner_error
self.owner_identity = owner_identity
self.owner_url = owner_url
self.loading_change = loading_change
self.reply_elsewhere = reply_elsewhere
self.candidates.extend([
{"source_type": "reply", "source_selector": "#parent .reply-one", "content": "已读回复甲"},
{"source_type": "reply", "source_selector": "#parent .reply-two", "content": "已读回复乙"},
])
async def evaluate(self, code, options):
if code == subject._OBSERVE_REPLY_CONTROL:
changed = bool(self.clicks and (self.owner_delta or self.loading_change))
return json.dumps({"url": self.url, "text": "展开 1 条回复", "owner_selector": "#parent",
"click_point": {"x": 100, "y": 80, "viewport_width": 400, "viewport_height": 600},
"owner_identity": "parent-comment", "reply_count": 2 + (self.owner_delta if self.clicks else 0),
"visible_reply_selectors": ["#parent .reply-one", "#parent .reply-two"] + (["#actual-reply"] if self.clicks and self.loads_reply else []),
"reply_snapshot_complete": True,
"fingerprint": "parent content" + (" changed" if changed else "")})
if code == subject._OBSERVE_REPLY_OWNER:
assert options == {"selector": "#parent"}
if self.owner_error:
return json.dumps({"error": "owner_not_unique"})
return json.dumps({"url": self.owner_url, "owner_identity": self.owner_identity,
"reply_count": 2 + self.owner_delta,
"visible_reply_selectors": ["#parent .reply-one", "#parent .reply-two"] + (["#actual-reply"] if self.loads_reply else []),
"reply_snapshot_complete": True})
observed = json.loads(await super().evaluate(code, options))
if self.clicks and self.loads_reply and self.reply_elsewhere:
observed["candidates"][-1]["source_selector"] = "#different-parent .new-reply"
return json.dumps(observed)
async def test_expand_program_fact_requires_new_independent_reply_and_same_owner_count_growth(context):
task_id, directory = context
page = OwnedExpansionPage()
result = await subject._expand_replies(task_id, ObservedSession(page), "#expand")
facts = subject.get_task_artifacts(task_id)["operation_evidence"]
assert result["status"] == "expanded" and result["parent_scope_checked"] is True
assert page.clicks == 1 and len(facts) == 1
fact = facts[0]
assert (fact["capability"], fact["source_kind"], fact["outcome"], fact["verifier"]) == (
"expand_replies", "reply", "success", "reply_delta_and_parent")
assert fact["evidence_refs"]["pre"] != fact["evidence_refs"]["post"]
assert fact["details"]["pre_snapshot"]["reply_count"] == 2
after = fact["details"]["post_snapshot"]
assert after["parent_scope_checked"] is True and after["owner_reply_delta"] == 1
assert after["reply_count"] == 3 and after["new_visible_replies"] == 1
assert len(after["new_reply_fingerprints"]) == 1
assert json.loads((directory / "evidence.json").read_text())["operation_evidence"] == facts
@pytest.mark.parametrize("case,options,owner_checked,owner_delta,new_count", [
("same_owner_short_wait_without_new_content", {"loads_reply": False, "owner_delta": 0}, True, 0, 0),
("loading_text_only", {"loads_reply": False, "owner_delta": 0, "loading_change": True}, True, 0, 0),
("count_increase_without_new_independent_reply", {"loads_reply": False}, True, 1, 0),
("reply_appears_under_another_owner", {"owner_delta": 0, "reply_elsewhere": True}, True, 0, 1),
("parent_identity_changed", {"owner_identity": "another-comment"}, False, None, 1),
("parent_cannot_be_uniquely_observed", {"owner_error": True}, False, None, 1),
("owner_belongs_to_different_note", {"owner_url": URL_B}, False, None, 1),
])
async def test_expand_insufficient_change_is_unknown_not_method_contradiction(
context, monkeypatch, case, options, owner_checked, owner_delta, new_count,
):
waits = []
async def short_render_wait(delay):
waits.append(delay)
monkeypatch.setattr(subject.asyncio, "sleep", short_render_wait)
task_id, directory = context
page = OwnedExpansionPage(**options)
result = await subject._expand_replies(task_id, ObservedSession(page), "#expand")
facts = subject.get_task_artifacts(task_id)["operation_evidence"]
assert page.clicks == 1 and result["comments_complete"] is False, case
assert len(facts) == 1 and facts[0]["outcome"] == "unknown", case
assert facts[0]["verifier"] == "reply_delta_and_parent"
after = facts[0]["details"]["post_snapshot"]
assert after["parent_scope_checked"] is owner_checked, case
assert after["owner_reply_delta"] == owner_delta, case
assert after["new_visible_replies"] == new_count, case
if not new_count:
assert waits == [0.25, 0.25, 0.25]
if case == "same_owner_short_wait_without_new_content":
assert result["status"] == "no_new_content"
if case == "reply_appears_under_another_owner":
assert result["status"] == "expanded" and result["parent_scope_checked"] is True
assert any(row["source_selector"] == "#different-parent .new-reply" for row in result["candidates"])
assert all(fact["outcome"] != "contradiction" for fact in facts)
assert json.loads((directory / "evidence.json").read_text())["operation_evidence"] == facts
class CumulativeHistory:
def __init__(self):
self.count = 0
def number_of_steps(self):
return self.count
def total_duration_seconds(self):
return 1.0
def is_done(self):
return True
def is_successful(self):
return True
def urls(self):
return [URL_A]
def action_names(self):
return []
def errors(self):
return []
def final_result(self):
return "本轮完成"
class CumulativeAgent:
"""Imitates the installed 0.13.10's inclusive cumulative run ceiling."""
def __init__(self):
self.state = SimpleNamespace(n_steps=1)
self.history = CumulativeHistory()
self.limits = []
async def run(self, max_steps, on_step_start=None):
self.limits.append(max_steps)
while self.state.n_steps <= max_steps:
if on_step_start:
await on_step_start(self)
self.history.count += 1
self.state.n_steps += 1
return self.history
async def test_same_agent_resumes_with_incremental_budget_instead_of_recounting_history(context):
agent = CumulativeAgent()
session = runner.LiveSession(agent=agent, browser=None)
first = await runner._run_agent(context[0], session, agent, max_steps=2, keep_session=True)
second = await runner._run_agent(context[0], session, agent, max_steps=3, keep_session=True)
assert agent.limits == [2, 5]
assert first["history"]["steps_this_run"] == 2
assert second["history"]["steps_this_run"] == 3
assert second["history"]["steps"] == 5 # Retained as explicitly cumulative history.
async def test_attempt_count_does_not_include_a_non_step_history_sentinel(context):
agent = CumulativeAgent()
original_run = agent.run
async def with_sentinel(max_steps, on_step_start=None):
history = await original_run(max_steps, on_step_start=on_step_start)
history.count += 1
return history
agent.run = with_sentinel
result = await runner._run_agent(context[0], runner.LiveSession(agent, None), agent, max_steps=1, keep_session=True)
assert result["history"]["steps"] == 2
assert result["history"]["steps_this_run"] == 1
async def test_new_agent_screenshot_sequence_cannot_overwrite_previous_evidence(tmp_path, monkeypatch):
(tmp_path / "step_001.png").write_bytes(b"first run")
rows = []
monkeypatch.setattr(runner, "append_progress", lambda *args, **kwargs: rows.append(args))
observation = SimpleNamespace(screenshot=base64.b64encode(b"second run").decode(), url=URL_A)
output = SimpleNamespace(next_goal="观察页面", evaluation_previous_goal="", action=[])
callback = runner._make_step_callback("same_task", tmp_path)
await callback(observation, output, 1)
assert (tmp_path / "step_001.png").read_bytes() == b"first run"
assert (tmp_path / "step_002.png").read_bytes() == b"second run"
assert rows[0][1] == "步骤 2"
async def test_budget_checkpoint_is_not_charged_again_by_finally(context):
task_id, directory = context
agent = CumulativeAgent()
session = runner.LiveSession(agent, None)
await runner._run_agent(task_id, session, agent, max_steps=2, keep_session=True)
first = json.loads((directory / "browser-budget.json").read_text())
assert first["total_steps_used"] == first["last_run_steps"] == 2
await runner._run_agent(task_id, session, agent, max_steps=3, keep_session=True)
second = json.loads((directory / "browser-budget.json").read_text())
assert second["total_steps_used"] == 5 and second["last_run_steps"] == 3
assert first["last_run_id"] != second["last_run_id"]
@pytest.mark.parametrize("failure", [RuntimeError("fixture browser failure"), asyncio.CancelledError()])
async def test_failed_or_cancelled_inflight_step_is_persisted_before_counter_advances(context, failure):
task_id, directory = context
agent = CumulativeAgent()
async def fail_mid_step(max_steps, on_step_start=None):
await on_step_start(agent)
during = json.loads((directory / "browser-budget.json").read_text())
assert during["total_steps_used"] == 1
raise failure
agent.run = fail_mid_step
with pytest.raises(type(failure)):
await runner._run_agent(task_id, runner.LiveSession(agent, None), agent, max_steps=3)
after = json.loads((directory / "browser-budget.json").read_text())
assert after["total_steps_used"] == after["last_run_steps"] == 1
assert agent.state.n_steps == 1
async def test_new_agent_uses_remaining_task_budget_and_keeps_legacy_floor(context):
task_id, directory = context
agent = CumulativeAgent()
result = await runner._run_agent(
task_id, runner.LiveSession(agent, None), agent, max_steps=40,
keep_session=True, budget_steps_used=58,
)
assert agent.limits == [2]
assert result["history"]["steps_this_run"] == 2
record = json.loads((directory / "browser-budget.json").read_text())
assert record["total_steps_used"] == 60
another = CumulativeAgent()
with pytest.raises(RuntimeError, match="额度已用完"):
await runner._run_agent(task_id, runner.LiveSession(another, None), another, max_steps=2, keep_session=True)
assert not another.limits
assert runner.read_browser_budget(task_id)["total_steps_used"] == 60
async def test_damaged_budget_ledger_is_not_reset(context):
task_id, directory = context
path = directory / "browser-budget.json"
path.write_text('{"total_steps_used":"bad","last_run_steps":0}')
agent = CumulativeAgent()
with pytest.raises(RuntimeError, match="台账无法核对"):
await runner._run_agent(task_id, runner.LiveSession(agent, None), agent, max_steps=2)
assert not agent.limits
assert json.loads(path.read_text())["total_steps_used"] == "bad"